From 8da50e955ed7b661e8963211d7f284dbd476e524 Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:39:18 -0500 Subject: [PATCH 1/2] feat(cli): link a prefilled GitHub issue from voltage feedback and panics `voltage feedback` prints a new-issue link with the build, platform, and the misbehaving command's words and flag names (never values), plus a link to search existing issues. A person at a terminal gets it opened in a browser; an agent or script never does, gets the report as JSON for `gh issue create`, and its report is marked agent-written. A panic hook prints the same link after the panic message, with the source line and a message scrubbed of IDs, keys, tokens, invoices, addresses, emails, and URLs. Every part is capped so the URL stays under 6,000 bytes. A bug report template gives manual reports the same sections. --- .github/ISSUE_TEMPLATE/bug_report.md | 26 ++ CHANGELOG.md | 1 + README.md | 2 +- docs/README.md | 2 +- docs/commands.md | 1 + docs/guides/scripting.md | 1 + docs/guides/troubleshooting.md | 14 + docs/quality.md | 3 +- scripts/check-coverage.py | 1 + src/cli.rs | 117 ++++++ src/feedback.rs | 514 +++++++++++++++++++++++++++ src/lib.rs | 1 + src/output.rs | 2 +- src/pick.rs | 1 + src/startup.rs | 21 +- tests/cli_integration.rs | 42 +++ 16 files changed, 743 insertions(+), 6 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 src/feedback.rs diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..0f1fd36 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,26 @@ +--- +name: Bug report +about: Something in the voltage CLI does not work as documented +labels: bug +--- + + + +### What happened + +What you expected, and what happened instead. + +### Steps to reproduce + +The commands to run, with placeholders such as `WALLET_ID` for IDs. + +### Environment + +- Version: (`voltage --version`) +- Platform: +- Command: + +### Checks + +- [ ] I searched existing issues: https://github.com/voltagecloud/voltage-cli/issues?q=is%3Aissue +- [ ] An AI agent wrote this report diff --git a/CHANGELOG.md b/CHANGELOG.md index 39995ce..5acd3d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - A payment that settles on a terminal closes with `⚡ Payment ID completed` instead of a check mark, printed after the payment's record so it stays on screen. - `voltage payments list --watch` follows payments until Ctrl-C: a live table that marks new and changed payments on a terminal, or one `snapshot`, `new`, or `updated` event per payment when piped or with `--json`. `--interval` sets the refresh (default 5 seconds). - `voltage` with no command, on a terminal, greets you with the wordmark, who you are signed in as, your saved scope, and commands to try; off a terminal it is still a usage error. An empty wallet, payment, or webhook list, and a new profile, end with a `›` line naming the next command. +- `voltage feedback` prints a link that opens a new GitHub issue with the CLI version, platform, and the misbehaving command's words and flag names (never their values) filled in, and a link to search existing issues first. On a terminal it opens the link in your browser unless `--no-browser`; off a terminal or with `--json` it never does, marks the report as agent-written, and returns the title, body, and labels for `gh issue create`. After a panic, the CLI prints the same kind of link with the source line and a scrubbed message. ## 0.2.0 - 2026-10-22 diff --git a/README.md b/README.md index 1bdbf66..d8cfbc2 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Every command also has `--help`. [The docs index](docs/README.md) lists everythi ## Contributing -Report security issues as described in [SECURITY.md](SECURITY.md). Build, test, and release instructions are in [the development guide](docs/development.md). Code conventions are in [the style guide](docs/quality.md). +Report security issues as described in [SECURITY.md](SECURITY.md). Report other problems with `voltage feedback`, which drafts a GitHub issue; see [report a problem](docs/guides/troubleshooting.md#report-a-problem). Build, test, and release instructions are in [the development guide](docs/development.md). Code conventions are in [the style guide](docs/quality.md). ## License diff --git a/docs/README.md b/docs/README.md index e8f3623..ef54c0c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ Start with the [README](../README.md) for install and a quick start, or follow [ - [Requests](guides/requests.md): request bodies, friendly flags, filters, paging - [Webhooks](guides/webhooks.md): create with a saved secret, events, deliveries, key rotation - [Checkout](guides/checkout.md): session and stream tokens, events -- [Troubleshooting](guides/troubleshooting.md): what each exit code and error means, and the fix +- [Troubleshooting](guides/troubleshooting.md): what each exit code and error means, and the fix, and how to report a problem - [Scripting](guides/scripting.md): JSON, `VOLTAGE_EXECUTE`, running without a person ## Look it up diff --git a/docs/commands.md b/docs/commands.md index 69fffd8..fe35ea1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -20,6 +20,7 @@ These commands use the auth service (`https://auth.voltage.cloud/api/v1`, overri | `voltage profiles list` | local only | | `voltage profiles delete NAME` | local only | | `voltage completions SHELL` | local only | +| `voltage feedback` | local only | - Every organization API call first exchanges the saved login for an organization token with `POST /api/v1/oauth/token`, and an expiring login is refreshed there as well. - A failed login revokes its new session with `POST /api/v1/oauth/revoke` so nothing is left running on the server. diff --git a/docs/guides/scripting.md b/docs/guides/scripting.md index 9bace58..c848ab0 100644 --- a/docs/guides/scripting.md +++ b/docs/guides/scripting.md @@ -23,5 +23,6 @@ VOLTAGE_EXECUTE=1 ./pay-invoices.sh - `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs. - Diagnostics, prompts, and progress go to stderr, so stdout carries only results. - Branch on [exit codes](../reference/exit-codes.md). Exit code 4 means the outcome is unknown: check the payment ID before you retry. +- An agent that finds a bug in the CLI can draft an issue with `voltage feedback --json`; see [report a problem](troubleshooting.md#report-a-problem). Related: [safety model](../concepts/safety-model.md), [output reference](../reference/output.md). diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index cc6a5de..bbaf35a 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -112,4 +112,18 @@ The CLI refuses a USD payment without `--quote` itself, with `A USD payment need When the detail names a quoted amount, the quote does not convert to exactly the payment's amount. Check what it converts to with `voltage quotes get QUOTE_ID`. To pay a BTC invoice from a USD wallet, quote the invoice's BTC amount with `--unit sats --to usd`. See [quotes and lines of credit](payments.md#quotes-and-lines-of-credit). +## Report a problem + +When the CLI does something wrong, `voltage feedback` prints a link that opens a new GitHub issue with the build, the platform, and a form for what happened and how to reproduce it. On a terminal it also opens the link in your browser; `--no-browser` only prints it. Name the command that misbehaved after `--`: the report keeps its command words and flag names, never their values. + +```sh +voltage feedback --title Balance-looks-wrong -- wallets get WALLET_ID --json +``` + +Search the existing issues first; the command prints that link too, and you can add to a matching issue instead of opening another. Before you submit, replace IDs, invoices, addresses, and keys with placeholders such as `WALLET_ID`. + +Off a terminal, or with `--json`, the command never opens a browser and marks the report as written by an AI agent. `--json` returns the issue's `title`, `labels`, `body`, `url`, and `search_url`, so an agent can search with `search_url` and then file it with `gh issue create --title TITLE --body BODY`, or give the `url` to a person. Leave the agent box checked when an agent wrote the report. + +If the CLI itself fails unexpectedly (a panic, exit code 101), it prints the same kind of link after the panic message, with the source line and a scrubbed message filled in. Scrubbing removes words that look like IDs, keys, tokens, invoices, addresses, emails, or URLs. + Related: [exit codes](../reference/exit-codes.md), [environment variables](../reference/environment.md), [log in and use profiles](login-and-profiles.md). diff --git a/docs/quality.md b/docs/quality.md index ae4c92a..ee04629 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -58,7 +58,8 @@ not precedent. Reviews must approve and document exceptions. `terminal`; the animated status line in `progress`; a payment wait's status lines in `timeline`; and the color palette and symbols in `style`. Keep which commands ask for missing scope, and in what order, in `pick`. Keep the default `--currency` of a payment, read from its - wallet's line of credit, in `wallet_currency`. Keep exit-code categories and + wallet's line of credit, in `wallet_currency`. Keep prefilled GitHub issue reports, their + scrubbing, and the panic hook that prints one in `feedback`. Keep exit-code categories and typed error detail in `error`. - Name each module for the operation that it performs. Do not give two modules the same capability name. For example, keep private-file mechanics in diff --git a/scripts/check-coverage.py b/scripts/check-coverage.py index c321b5a..2dc8ee6 100644 --- a/scripts/check-coverage.py +++ b/scripts/check-coverage.py @@ -27,6 +27,7 @@ ('voltage profiles list', []), ('voltage profiles delete NAME', []), ('voltage completions SHELL', []), + ('voltage feedback', []), ] # Commands that use the coinprice service, with the routes they call. PRICE_COMMANDS = [ diff --git a/src/cli.rs b/src/cli.rs index d3899d4..4ed622b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -216,6 +216,8 @@ pub enum LocalCommand { Price(PriceFlags), /// Convert an amount between BTC and USD at the current or a past price Convert(ConvertFlags), + /// Print a link that files a GitHub issue with this build and platform filled in + Feedback(FeedbackFlags), /// Print shell completions Completions { #[arg(value_enum)] @@ -230,6 +232,30 @@ pub struct PriceFlags { pub at: Option, } +#[derive(Debug, Args)] +pub struct FeedbackFlags { + /// Issue title: what went wrong, in one line + #[arg(long)] + pub title: Option, + /// Print the link without opening it + #[arg(long)] + pub no_browser: bool, + /// The command that misbehaved, after `--`; the report keeps only its command words and + /// flag names, never their values + #[arg(last = true, value_name = "COMMAND")] + pub command: Vec, +} + +impl FeedbackFlags { + /// The misbehaving command as the report shows it, when one was given. + pub fn command_outline(&self) -> Option { + let args: Vec = std::iter::once(OsString::from("voltage")) + .chain(self.command.iter().cloned()) + .collect(); + (!self.command.is_empty()).then(|| outline(&args)) + } +} + #[derive(Debug, Args)] pub struct ConvertFlags { /// Exact decimal amount @@ -776,6 +802,34 @@ where .any(|arg| arg.as_ref() == OsStr::new("--json")) } +/// A command line as an issue report shows it: `voltage`, the subcommand words, and each +/// flag's name, without positional values or flag values, which can hold IDs and secrets. +/// It reads the command tree, not a parse, so it works on a line that does not parse. +pub fn outline(args: &[OsString]) -> String { + let root = command(); + let mut current = &root; + let mut words = vec!["voltage".to_owned()]; + for arg in args.iter().skip(1).map(|arg| arg.to_string_lossy()) { + if arg == "--" { + break; + } + if let Some(long) = arg.strip_prefix("--") { + words.push(format!("--{}", long.split('=').next().unwrap_or_default())); + } else if let Some(short) = arg + .strip_prefix('-') + .and_then(|rest| rest.chars().next()) + .filter(char::is_ascii_alphabetic) + { + // `-ojson` keeps only `-o`; `-5` is a value, not a flag. + words.push(format!("-{short}")); + } else if let Some(sub) = current.find_subcommand(arg.as_ref()) { + words.push(sub.get_name().to_owned()); + current = sub; + } + } + words.join(" ") +} + fn parse(matches: &ArgMatches) -> Result { T::from_arg_matches(matches).map_err(|error| Error::usage(error.to_string())) } @@ -1316,6 +1370,7 @@ fn guide(path: &[&str]) -> &'static str { .., ] => "guides/payments.md", ["completions", ..] => "guides/install.md", + ["feedback", ..] => "guides/troubleshooting.md", _ => "README.md", } } @@ -1346,6 +1401,12 @@ fn local_examples(path: &[&str]) -> Option<&'static str> { ["context"] => { " # Show what a command with this profile would use, and where each value came from voltage context --profile prod" + } + ["feedback"] => { + " # File an issue about a command; the link keeps its flag names, not their values + voltage feedback --title Balance-looks-wrong -- wallets get WALLET_ID --json + # Print the prefilled issue as JSON, for an agent that files it with `gh issue create` + voltage feedback --json" } ["convert"] => { " # What $10 buys in bitcoin now, and 21,000 sats in dollars on a past date @@ -1838,6 +1899,62 @@ mod tests { ); } + #[test] + fn an_outline_keeps_command_words_and_flag_names_but_no_values() { + let args = |words: &[&str]| words.iter().map(OsString::from).collect::>(); + assert_eq!( + outline(&args(&[ + "voltage", + "--profile", + "prod", + "wallets", + "get", + "6f1c2a9e-1b2d-4c3e-8f40-0a1b2c3d4e5f", + "--data=@secret.json", + "-ojson", + "-q", + ])), + "voltage --profile wallets get --data -o -q" + ); + // A negative amount is a value, and nothing after `--` is read. + assert_eq!( + outline(&args(&["voltage", "convert", "-5", "usd", "--", "--to"])), + "voltage convert" + ); + // A line that does not parse still outlines. + assert_eq!( + outline(&args(&["voltage", "walets", "--bogus"])), + "voltage --bogus" + ); + let feedback = |words: &[&str]| { + let Command::Local(LocalCommand::Feedback(flags)) = + Invocation::try_parse_from(words).unwrap().command + else { + panic!("expected feedback"); + }; + flags + }; + let flags = feedback(&[ + "voltage", + "feedback", + "--title", + "Broken", + "--no-browser", + "--", + "payments", + "send", + "lnbc1secret", + "--execute", + ]); + assert_eq!(flags.title.as_deref(), Some("Broken")); + assert!(flags.no_browser); + assert_eq!( + flags.command_outline().as_deref(), + Some("voltage payments send --execute") + ); + assert_eq!(feedback(&["voltage", "feedback"]).command_outline(), None); + } + #[test] fn local_commands_parse_into_their_variants() { let login = Invocation::try_parse_from([ diff --git a/src/feedback.rs b/src/feedback.rs new file mode 100644 index 0000000..0a21874 --- /dev/null +++ b/src/feedback.rs @@ -0,0 +1,514 @@ +//! Prefilled GitHub issue links, for `voltage feedback` and after a panic. +//! +//! A report carries only what helps triage and cannot identify an account: the build, the +//! platform, the command's words and flag names (never flag values), and a scrubbed panic. +//! Scrubbing replaces any word that looks like an ID, key, token, invoice, address, email, or +//! URL, so text that echoes the API or a flag value cannot leak a secret into a public issue. + +use crate::{ + cli::{FeedbackFlags, outline}, + output::REDACTED, + style::{Line, Role}, + terminal::scrub_line, +}; +use serde::Serialize; +use std::{ + io::IsTerminal, + panic::{Location, PanicHookInfo}, + path::Path, +}; +use url::form_urlencoded; + +const REPOSITORY: &str = "https://github.com/voltagecloud/voltage-cli"; +/// GitHub answers longer `issues/new` URLs with an error, and terminals wrap them badly. +const MAX_URL: usize = 6000; +const MAX_TITLE: usize = 120; +const MAX_PROBLEM: usize = 500; +const MAX_COMMAND: usize = 300; +/// A shorter word with a digit is a count, a status, or a version, not a credential. +const MIN_SECRET_WITH_DIGIT: usize = 16; +/// A word this long is redacted even without a digit, such as a hex or base64 secret. +const MIN_SECRET: usize = 32; + +/// Who is filing. Only a person at a terminal is a person; a process without a terminal or +/// one that asked for JSON is almost always an agent or a script, so its report says so. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Reporter { + Person, + Agent, +} + +impl Reporter { + pub fn detect(interactive: bool) -> Self { + if interactive { + Self::Person + } else { + Self::Agent + } + } +} + +/// `voltage feedback`'s result: the prefilled issue, and whether a browser opened it. +#[derive(Debug, Serialize)] +pub struct Feedback { + #[serde(flatten)] + pub report: IssueReport, + pub opened: bool, +} + +/// The report for `voltage feedback`. Only a person at a terminal who did not pass +/// `--no-browser` gets a browser; an agent or script gets the link alone. +pub fn run(flags: &FeedbackFlags, interactive: bool) -> Feedback { + let report = Draft { + command: flags.command_outline(), + title: flags.title.clone(), + panic: None, + reporter: Reporter::detect(interactive), + } + .into_report(); + let opened = interactive && !flags.no_browser && webbrowser::open(&report.url).is_ok(); + Feedback { report, opened } +} + +impl Feedback { + /// On a terminal, what a person reads instead of the report's fields: where to search + /// first, then the link that files the issue. + pub fn lines(&self, step: &'static str) -> Vec { + let file = if self.opened { + " Opened in your browser: " + } else { + " File the issue: " + }; + vec![ + Line::default() + .paint(Role::Hint, step) + .text(" Search existing issues first: ") + .link(&self.report.search_url), + Line::default() + .paint(Role::Hint, step) + .text(file) + .link(&self.report.url), + ] + } +} + +/// What a report says before it becomes a link. +#[derive(Debug)] +pub struct Draft { + /// The command's words and flag names, such as `voltage wallets get --json`. + pub command: Option, + pub title: Option, + pub panic: Option, + pub reporter: Reporter, +} + +/// Where the CLI panicked, and its message, which is scrubbed when the report is built. +#[derive(Debug)] +pub struct Panic { + /// `file:line` in the CLI's source, or a dependency's file name. + pub at: Option, + pub message: String, +} + +impl Panic { + /// The CLI's own files are relative paths. A dependency's is absolute and can name the + /// builder's home directory, so only its file name is kept. + fn new(location: Option<&Location<'_>>, message: &str) -> Self { + Self { + at: location + .map(|location| format!("{}:{}", source_file(location.file()), location.line())), + message: message.to_owned(), + } + } + + fn of(info: &PanicHookInfo<'_>) -> Self { + Self::new(info.location(), info.payload_as_str().unwrap_or_default()) + } + + fn describe(&self) -> String { + let message = scrub(&self.message, MAX_PROBLEM); + match &self.at { + Some(at) => format!("voltage panicked at {at}: {message}"), + None => format!("voltage panicked: {message}"), + } + } +} + +/// A prefilled issue: the link opens GitHub's new-issue form with these fields filled in. +#[derive(Debug, Serialize)] +pub struct IssueReport { + pub title: String, + pub labels: Vec<&'static str>, + pub reporter: Reporter, + pub body: String, + pub url: String, + /// Existing issues to search before filing another. + pub search_url: String, +} + +impl Draft { + pub fn into_report(self) -> IssueReport { + let title = self + .title + .as_deref() + .map(|title| scrub(title, MAX_TITLE)) + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| "Describe the problem in one line".into()); + let labels = vec!["bug"]; + let search_url = format!("{REPOSITORY}/issues?q=is%3Aissue"); + let mut body = self.body(&search_url); + let mut url = new_issue_url(&title, &body, &labels); + // Every part is already capped, so this only trims a report that is still too long. + while url.len() > MAX_URL { + let keep = body.chars().count() * 9 / 10; + body = format!("{}…", body.chars().take(keep).collect::()); + url = new_issue_url(&title, &body, &labels); + } + IssueReport { + title, + labels, + reporter: self.reporter, + body, + url, + search_url, + } + } + + fn body(&self, search_url: &str) -> String { + let problem = self.panic.as_ref().map_or_else( + || "What you expected, and what happened instead.".into(), + Panic::describe, + ); + let command = self.command.as_deref().map_or_else( + || "not given".into(), + |command| format!("`{}`", scrub(command, MAX_COMMAND)), + ); + let agent = match self.reporter { + Reporter::Agent => "x", + Reporter::Person => " ", + }; + format!( + " + +### What happened + +{problem} + +### Steps to reproduce + +The commands to run, with placeholders such as `WALLET_ID` for IDs. + +### Environment + +- Version: voltage {version} +- Platform: {os} {arch} +- Command: {command} + +### Checks + +- [ ] I searched existing issues: {search_url} +- [{agent}] An AI agent wrote this report +", + version = env!("VOLTAGE_VERSION"), + os = std::env::consts::OS, + arch = std::env::consts::ARCH, + ) + } +} + +fn new_issue_url(title: &str, body: &str, labels: &[&str]) -> String { + let query = form_urlencoded::Serializer::new(String::new()) + .append_pair("title", title) + .append_pair("body", body) + .append_pair("labels", &labels.join(",")) + .finish(); + format!("{REPOSITORY}/issues/new?{query}") +} + +/// Text for a public issue: one line, no word that could be a secret or identify an account, +/// and at most `limit` characters. +pub fn scrub(text: &str, limit: usize) -> String { + let line = scrub_line(text); + let mut scrubbed = String::with_capacity(line.len()); + let mut word = String::new(); + for c in line.chars() { + if is_word(c) { + word.push(c); + } else { + push_word(&mut scrubbed, &mut word); + scrubbed.push(c); + } + } + push_word(&mut scrubbed, &mut word); + if scrubbed.chars().count() > limit { + scrubbed = scrubbed.chars().take(limit).collect(); + scrubbed.push('…'); + } + scrubbed +} + +/// Characters that IDs, keys, invoices, addresses, emails, and URLs are made of. +fn is_word(c: char) -> bool { + c.is_alphanumeric() || "-_./:+=@%~?&#".contains(c) +} + +fn push_word(scrubbed: &mut String, word: &mut String) { + let length = word.chars().count(); + let secret = word.contains('@') + || word.contains("://") + || length >= MIN_SECRET + || (length >= MIN_SECRET_WITH_DIGIT && word.chars().any(|c| c.is_ascii_digit())); + scrubbed.push_str(if secret { REDACTED } else { word }); + word.clear(); +} + +/// After the default panic report, print a link that files the panic as a bug. The command's +/// words are read when it panics, so a run that never panics never builds the command tree. +pub fn report_panics(reporter: Reporter) { + let default = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + default(info); + let args: Vec<_> = std::env::args_os().collect(); + let report = Draft { + command: Some(outline(&args)), + title: Some("voltage panicked".into()), + panic: Some(Panic::of(info)), + reporter, + } + .into_report(); + eprintln!("This is a bug in voltage. Report it: {}", report.url); + })); +} + +fn source_file(file: &str) -> std::borrow::Cow<'_, str> { + let path = Path::new(file); + if path.is_absolute() { + path.file_name().unwrap_or_default().to_string_lossy() + } else { + file.into() + } +} + +/// A panic's report marks an agent the same way `voltage feedback` does. +pub fn interactive(json: bool) -> bool { + !json && std::io::stdout().is_terminal() && std::io::stdin().is_terminal() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn query(url: &str) -> HashMap { + let url = url::Url::parse(url).unwrap(); + url.query_pairs().into_owned().collect() + } + + fn draft(message: Option<&str>, reporter: Reporter) -> Draft { + Draft { + command: Some("voltage wallets get --json".into()), + title: Some("Balance looks wrong".into()), + panic: message.map(|message| Panic::new(None, message)), + reporter, + } + } + + #[test] + fn a_report_links_to_a_prefilled_new_issue() { + let report = draft(Some("index out of bounds"), Reporter::Person).into_report(); + assert!( + report + .url + .starts_with("https://github.com/voltagecloud/voltage-cli/issues/new?") + ); + let fields = query(&report.url); + assert_eq!(fields["title"], "Balance looks wrong"); + assert_eq!(fields["labels"], "bug"); + assert_eq!(fields["body"], report.body); + for expected in [ + "voltage panicked: index out of bounds", + concat!("- Version: voltage ", env!("VOLTAGE_VERSION")), + &format!( + "- Platform: {} {}", + std::env::consts::OS, + std::env::consts::ARCH + ), + "- Command: `voltage wallets get --json`", + "- [ ] I searched existing issues: https://github.com/voltagecloud/voltage-cli/issues?q=is%3Aissue", + "- [ ] An AI agent wrote this report", + ] { + assert!( + report.body.contains(expected), + "{expected}\n{}", + report.body + ); + } + // The URL is fully encoded: no raw space, newline, or `#` that would end it early. + assert!(!report.url.contains([' ', '\n', '#']), "{}", report.url); + } + + #[test] + fn an_agent_report_is_marked_and_asks_to_search_first() { + let report = draft(None, Reporter::Agent).into_report(); + assert!(report.body.contains("- [x] An AI agent wrote this report")); + assert!(report.body.contains("Search existing issues first")); + assert!(report.body.contains("What you expected")); + assert_eq!(report.reporter, Reporter::Agent); + assert_eq!( + serde_json::to_value(Reporter::Agent).unwrap(), + serde_json::json!("agent") + ); + assert_eq!(Reporter::detect(false), Reporter::Agent); + assert_eq!(Reporter::detect(true), Reporter::Person); + } + + #[test] + fn a_report_without_a_title_or_command_asks_for_them() { + let report = Draft { + command: None, + title: Some(" ".into()), + panic: None, + reporter: Reporter::Person, + } + .into_report(); + assert_eq!(report.title, "Describe the problem in one line"); + assert!(report.body.contains("- Command: not given")); + } + + fn flags(no_browser: bool) -> FeedbackFlags { + FeedbackFlags { + title: Some("Broken".into()), + no_browser, + command: vec!["wallets".into(), "list".into(), "--json".into()], + } + } + + #[test] + fn feedback_never_opens_a_browser_for_an_agent_or_with_no_browser() { + let agent = run(&flags(false), false); + assert!(!agent.opened); + assert_eq!(agent.report.reporter, Reporter::Agent); + assert!( + agent + .report + .body + .contains("- Command: `voltage wallets list --json`") + ); + let person = run(&flags(true), true); + assert!(!person.opened); + assert_eq!(person.report.reporter, Reporter::Person); + let json = serde_json::to_value(&person).unwrap(); + for field in [ + "title", + "labels", + "reporter", + "body", + "url", + "search_url", + "opened", + ] { + assert!(json.get(field).is_some(), "{field}: {json}"); + } + } + + #[test] + fn a_terminal_shows_where_to_search_then_where_to_file() { + let mut filed = run(&flags(true), false); + let lines = |filed: &Feedback| { + filed + .lines(">") + .iter() + .map(|line| anstream::adapter::strip_str(line.as_str()).to_string()) + .collect::>() + }; + let shown = lines(&filed); + assert!(shown[0].starts_with("> Search existing issues first: https://")); + assert!(shown[1].starts_with("> File the issue: https://")); + filed.opened = true; + assert!(lines(&filed)[1].starts_with("> Opened in your browser: https://")); + } + + #[test] + fn scrubbing_removes_ids_keys_invoices_emails_and_urls() { + let text = "wallet 6f1c2a9e-1b2d-4c3e-8f40-0a1b2c3d4e5f key vltg_sk_1234567890abcdef \ + invoice lnbc2500n1pj9xyzabcdefghijklmnop from me@example.com at \ + https://api.voltage.cloud/x token abcdefabcdefabcdefabcdefabcdefabcd"; + let scrubbed = scrub(text, 1000); + assert_eq!( + scrubbed, + "wallet [REDACTED] key [REDACTED] invoice [REDACTED] from [REDACTED] at [REDACTED] \ + token [REDACTED]" + ); + // Ordinary words, statuses, and short numbers stay readable. + assert_eq!( + scrub("HTTP 404: wallet not found (exit code 1)", 1000), + "HTTP 404: wallet not found (exit code 1)" + ); + // Control characters cannot reach the terminal or the issue. + assert_eq!(scrub("a\nb\u{1b}[31mc", 1000), "a b [31mc"); + } + + #[test] + fn every_part_of_a_report_is_capped() { + assert_eq!(scrub(&"word ".repeat(100), 10), "word word …"); + let long = "too long ".repeat(10_000); + let report = Draft { + command: Some(long.clone()), + title: Some(long.clone()), + panic: Some(Panic::new(None, &long)), + reporter: Reporter::Agent, + } + .into_report(); + assert!(report.url.len() <= MAX_URL, "{}", report.url.len()); + assert_eq!(report.title.chars().count(), MAX_TITLE + 1); + // Within the caps the whole body still fits, checks and all. + assert!(report.body.contains("An AI agent wrote this report")); + } + + #[test] + fn a_body_too_long_for_a_link_is_trimmed_until_it_fits() { + // Multibyte text encodes to nine bytes a character, so the capped parts overflow. + let report = Draft { + command: Some("€".repeat(10_000)), + title: Some("€".repeat(10_000)), + panic: Some(Panic::new(None, &"€".repeat(10_000))), + reporter: Reporter::Person, + } + .into_report(); + assert!(report.url.len() <= MAX_URL, "{}", report.url.len()); + assert!(report.body.ends_with('…')); + assert_eq!(query(&report.url)["body"], report.body); + } + + #[test] + fn a_panic_names_its_source_line_and_a_scrubbed_message() { + let location = Location::caller(); + let panic = Panic::new( + Some(location), + "called `Result::unwrap()` on wallet 6f1c2a9e-1b2d-4c3e-8f40-0a1b2c3d4e5f", + ); + // The source line survives scrubbing, which would take it for an ID. + assert_eq!( + panic.describe(), + format!( + "voltage panicked at {}:{}: called `Result::unwrap()` on wallet [REDACTED]", + location.file(), + location.line() + ) + ); + assert_eq!( + Panic::new(None, "boom").describe(), + "voltage panicked: boom" + ); + // A dependency's absolute path keeps only its file name, never the home directory. + let home = std::env::temp_dir() + .join("someone") + .join("registry") + .join("lib.rs"); + assert_eq!(source_file(&home.to_string_lossy()), "lib.rs"); + assert_eq!(source_file("src/api.rs"), "src/api.rs"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2ba5f87..f06a578 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod config; mod context; mod dry_run; mod error; +mod feedback; mod greeting; mod input; mod output; diff --git a/src/output.rs b/src/output.rs index 74bdfb3..6a8ad2e 100644 --- a/src/output.rs +++ b/src/output.rs @@ -40,7 +40,7 @@ const SECRET_KEYS: [&str; 12] = [ "preimage", ]; -const REDACTED: &str = "[REDACTED]"; +pub(crate) const REDACTED: &str = "[REDACTED]"; /// Stdout rendering selected by `--json`, `--output`, or the terminal. #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] diff --git a/src/pick.rs b/src/pick.rs index 2d1efe3..573108d 100644 --- a/src/pick.rs +++ b/src/pick.rs @@ -74,6 +74,7 @@ fn needs(command: &Command) -> Needs { | LocalCommand::Context | LocalCommand::Price(_) | LocalCommand::Convert(_) + | LocalCommand::Feedback(_) | LocalCommand::Completions { .. }, ) => Needs::default(), } diff --git a/src/startup.rs b/src/startup.rs index ab3a316..89e99e6 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -5,12 +5,13 @@ use crate::{ api::{self, Execution, SubmissionState}, auth::{self, Discovery, Resolver}, cli::{ - self, AuthCommand, Command, ConvertFlags, GlobalFlags, Invocation, LocalCommand, - ParseFailure, PriceFlags, ProfileCommand, + self, AuthCommand, Command, ConvertFlags, FeedbackFlags, GlobalFlags, Invocation, + LocalCommand, ParseFailure, PriceFlags, ProfileCommand, }, config::{self, Profile, Scope, Settings}, context, error::{CredentialSources, ErrorKind}, + feedback::{self, Reporter}, greeting, output::{Envelope, NextStep, Output, OutputFormat, report_error, write_stdout}, pick, @@ -25,6 +26,7 @@ const BINARY_NAME: &str = "voltage"; pub async fn run() -> ExitCode { let json_errors = cli::json_errors_requested(std::env::args_os()); + feedback::report_panics(Reporter::detect(feedback::interactive(json_errors))); let color = ColorChoice::requested(std::env::args_os()) .with_environment(|name| std::env::var(name).ok()); if let Some(global) = greeting::wanted(&std::env::args_os().collect::>()) { @@ -126,6 +128,10 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result global.quiet, ) }; + // Feedback needs neither configuration nor credentials. + if let Command::Local(LocalCommand::Feedback(flags)) = &command { + return file_feedback(flags, &global, output(true)?); + } // Price commands need neither configuration nor credentials. let command = match PriceQuery::try_from(command) { Ok(query) => { @@ -158,6 +164,16 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result } } +/// `voltage feedback`: a person at a terminal reads two links; anything else gets the report. +fn file_feedback(flags: &FeedbackFlags, global: &GlobalFlags, mut out: Output) -> Result<()> { + let terminal = global.terminal(); + let filed = feedback::run(flags, out.on_terminal() && terminal.can_prompt()); + if out.on_terminal() { + return out.preface(&filed.lines(terminal.glyphs().step)); + } + out.write(Envelope::local(filed)?, &[]) +} + /// One command once its configuration is read and its scope is complete: what the flags /// asked for, the saved settings, the scope, and what the command may have changed. /// Commands run from here; what they call receives only the parts it uses. @@ -251,6 +267,7 @@ impl<'a> Session<'a> { profiles(command, &mut self.settings, scope, global).await } LocalCommand::Completions { .. } + | LocalCommand::Feedback(_) | LocalCommand::Price(_) | LocalCommand::Convert(_) => Err(Error::usage("This command needs no configuration")), } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 53c22e4..a38fcef 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -109,6 +109,48 @@ fn version_leads_with_the_cargo_version_and_marks_the_build_separately() { ); } +#[test] +fn feedback_prints_a_scrubbed_issue_link_without_configuration_or_a_browser() { + let dir = private_tempdir(); + let wallet = "6f1c2a9e-1b2d-4c3e-8f40-0a1b2c3d4e5f"; + // An unreadable configuration directory does not matter: feedback reads no settings. + let result = Command::new(assert_cmd::cargo::cargo_bin!("voltage")) + .arg("--config-dir") + .arg(dir.path().join("missing")) + .args([ + "feedback", "--json", "--title", "Wallet", "--", "wallets", "get", + ]) + .args([wallet, "--profile", "secret-profile", "--json"]) + .assert() + .success(); + let output = json_stdout(&result); + let data = &output["data"]; + assert_eq!(data["opened"], false); + assert_eq!(data["reporter"], "agent"); + assert_eq!(data["labels"], json!(["bug"])); + let url = data["url"].as_str().unwrap(); + assert!( + url.starts_with( + "https://github.com/voltagecloud/voltage-cli/issues/new?title=Wallet&body=" + ) + ); + let body = data["body"].as_str().unwrap(); + assert!( + body.contains("- Command: `voltage wallets get --profile --json`"), + "{body}" + ); + assert!( + body.contains("- [x] An AI agent wrote this report"), + "{body}" + ); + let stdout = stdout(&result); + assert!( + !stdout.contains(wallet) && !stdout.contains("secret-profile"), + "{stdout}" + ); + assert!(result.get_output().stderr.is_empty()); +} + #[test] fn human_runtime_errors_have_a_prefix_and_safe_hint() { let dir = private_tempdir(); From 4e918543ed4287de5a0bae444fddf0a6334ad1bc Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:09:59 -0500 Subject: [PATCH 2/2] fix(cli): skip printing the issue link once a browser opens it The prefilled link wrapped across about nine terminal lines. When a browser opens it, the terminal now says so instead of printing it, and a person's report leaves out the agent instructions, so a --no-browser link stays near 700 characters. Agent and --json reports are unchanged. --- CHANGELOG.md | 2 +- docs/guides/troubleshooting.md | 2 +- src/feedback.rs | 40 ++++++++++++++++++++-------------- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5acd3d4..431c04c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - A payment that settles on a terminal closes with `⚡ Payment ID completed` instead of a check mark, printed after the payment's record so it stays on screen. - `voltage payments list --watch` follows payments until Ctrl-C: a live table that marks new and changed payments on a terminal, or one `snapshot`, `new`, or `updated` event per payment when piped or with `--json`. `--interval` sets the refresh (default 5 seconds). - `voltage` with no command, on a terminal, greets you with the wordmark, who you are signed in as, your saved scope, and commands to try; off a terminal it is still a usage error. An empty wallet, payment, or webhook list, and a new profile, end with a `›` line naming the next command. -- `voltage feedback` prints a link that opens a new GitHub issue with the CLI version, platform, and the misbehaving command's words and flag names (never their values) filled in, and a link to search existing issues first. On a terminal it opens the link in your browser unless `--no-browser`; off a terminal or with `--json` it never does, marks the report as agent-written, and returns the title, body, and labels for `gh issue create`. After a panic, the CLI prints the same kind of link with the source line and a scrubbed message. +- `voltage feedback` prints a link that opens a new GitHub issue with the CLI version, platform, and the misbehaving command's words and flag names (never their values) filled in, and a link to search existing issues first. On a terminal it opens the link in your browser instead of printing it, unless `--no-browser`; off a terminal or with `--json` it never does, marks the report as agent-written, and returns the title, body, and labels for `gh issue create`. After a panic, the CLI prints the same kind of link with the source line and a scrubbed message. ## 0.2.0 - 2026-10-22 diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index bbaf35a..69e294b 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -114,7 +114,7 @@ When the detail names a quoted amount, the quote does not convert to exactly the ## Report a problem -When the CLI does something wrong, `voltage feedback` prints a link that opens a new GitHub issue with the build, the platform, and a form for what happened and how to reproduce it. On a terminal it also opens the link in your browser; `--no-browser` only prints it. Name the command that misbehaved after `--`: the report keeps its command words and flag names, never their values. +When the CLI does something wrong, `voltage feedback` prints a link that opens a new GitHub issue with the build, the platform, and a form for what happened and how to reproduce it. On a terminal it opens the link in your browser instead of printing it; `--no-browser` prints it. Name the command that misbehaved after `--`: the report keeps its command words and flag names, never their values. ```sh voltage feedback --title Balance-looks-wrong -- wallets get WALLET_ID --json diff --git a/src/feedback.rs b/src/feedback.rs index 0a21874..473a519 100644 --- a/src/feedback.rs +++ b/src/feedback.rs @@ -73,22 +73,21 @@ pub fn run(flags: &FeedbackFlags, interactive: bool) -> Feedback { impl Feedback { /// On a terminal, what a person reads instead of the report's fields: where to search - /// first, then the link that files the issue. + /// first, then the link that files the issue. The prefilled link wraps across many lines, + /// so it is printed only when no browser opened it. pub fn lines(&self, step: &'static str) -> Vec { + let file = Line::default().paint(Role::Hint, step); let file = if self.opened { - " Opened in your browser: " + file.text(" Opened the prefilled issue in your browser") } else { - " File the issue: " + file.text(" File the issue: ").link(&self.report.url) }; vec![ Line::default() .paint(Role::Hint, step) .text(" Search existing issues first: ") .link(&self.report.search_url), - Line::default() - .paint(Role::Hint, step) - .text(file) - .link(&self.report.url), + file, ] } } @@ -184,16 +183,20 @@ impl Draft { || "not given".into(), |command| format!("`{}`", scrub(command, MAX_COMMAND)), ); - let agent = match self.reporter { - Reporter::Agent => "x", - Reporter::Person => " ", + // A person's link is printed on a terminal, so only an agent's report carries the + // instructions a person already read there. + let (agent, preface) = match self.reporter { + Reporter::Agent => ( + "x", + "\n\n", + ), + Reporter::Person => (" ", ""), }; format!( - " - -### What happened + "{preface}### What happened {problem} @@ -346,6 +349,8 @@ mod tests { report.body ); } + // A person read the instructions on the terminal, so the link leaves them out. + assert!(!report.body.contains("