Skip to content

Commit 6919721

Browse files
jamesadevineCopilot
andcommitted
fix(cli): correct parse_parameters doc; cap consecutive poll errors; warn on empty status
Three follow-ups on PR #602: 1. `parse_parameters` doc-comment bug: the first bullet was tagged ✅ but described the same broken case as the third (✅ `--parameters 'urls=a,b' --parameters mode=fast` still splits on the comma inside `urls=a,b` and fails on the trailing `b` fragment). Rewrote the bullet list so all broken examples are ❌ and only the genuine "one pair per flag, no commas in values" workaround is ✅. Also clarified that there is currently no way to escape a comma inside a single `--parameters` argument, and pointed at the existing `parse_parameters_values_with_commas_split_pre_equals` unit test as the behaviour anchor. 2. `poll_until_complete` couldn't distinguish a permanent error (deleted build, revoked PAT, 404) from a transient one — both pushed the target back onto `next_pending` and silently retried until `--timeout`. Added a per-build `consecutive_errors: HashMap<u64, usize>` counter that resets on any successful poll and bails out of that specific build after `MAX_CONSECUTIVE_POLL_ERRORS = 3` consecutive failures, counting it as failed. Transient blips still retry; persistent failures surface within `3 × poll_interval` (default 30s) instead of waiting out the full `--timeout` (default 1800s). 3. `status` was silently rendering `(no matched definitions)` when the fixture matcher returned zero hits, which is indistinguishable from running in the wrong directory. Added an `eprintln!` warning that mirrors the existing `failed to scan local pipelines: …` message. The command stays non-fatal (read-only) by design, unlike `disable` which bails. `cargo test` (1568 unit + 14 integration crates, all green) and `cargo clippy --all-targets --all-features` pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 435a55f commit 6919721

2 files changed

Lines changed: 89 additions & 35 deletions

File tree

src/run.rs

Lines changed: 77 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,28 @@ use crate::detect;
2525
///
2626
/// **Values must not contain commas.** Each raw argument is split on
2727
/// `,` *before* the `=` split, so a value like `redirect_uri=https://a,b`
28-
/// silently becomes `{"redirect_uri": "https://a", "b": "?"}` (and
29-
/// actually errors out since `b` has no `=`). When a parameter value
30-
/// must include a comma, pass it via a dedicated `--parameters`
31-
/// occurrence rather than a comma-joined one:
28+
/// is torn into two pairs and the trailing fragment (`b`) is rejected
29+
/// because it has no `=`.
3230
///
33-
/// - ✅ `--parameters 'urls=a,b' --parameters mode=fast` → two flags
34-
/// still split `urls=a,b` on the comma. Use the workaround below.
35-
/// - ✅ `--parameters mode=fast` plus an explicit subsequent
36-
/// `--parameters extra=…` — one pair per flag, no comma in values.
37-
/// - ❌ `--parameters key=a,b` will parse as two pairs and reject
38-
/// the second.
31+
/// There is currently no way to escape a comma inside a single
32+
/// `--parameters` argument. The CLI also splits any single argument
33+
/// on `,`, so passing the comma-containing value as a separate flag
34+
/// does **not** help either — it's the comma in the argument value
35+
/// (not the argument boundary) that matters.
3936
///
40-
/// The CLI does not currently support escaping commas inside a single
41-
/// `--parameters` argument; users who need that should fall back to
42-
/// repeated `--parameters` flags (one pair each).
37+
/// - ❌ `--parameters key=a,b`
38+
/// → splits to `key=a` + `b`; the second pair fails with `no '='`.
39+
/// - ❌ `--parameters 'urls=a,b' --parameters mode=fast`
40+
/// → same split happens inside the first argument; the result is
41+
/// `key=urls=a` + `b` + `mode=fast` and the `b` fragment is rejected.
42+
/// - ✅ `--parameters mode=fast --parameters extra=x`
43+
/// → one pair per flag, no commas in values; both pairs parse.
44+
///
45+
/// If you need to pass a comma in a value, the only workaround today is
46+
/// to write the value without the comma (e.g. URL-encode it on the
47+
/// caller side and have the pipeline decode it). A follow-up could add
48+
/// escape syntax (`--parameters 'urls=a\,b'`) without breaking this
49+
/// rule.
4350
///
4451
/// Only the first `=` in a pair is treated as the separator; subsequent
4552
/// `=` characters are part of the value, so `key=a=b=c` parses as
@@ -256,6 +263,12 @@ struct PollOutcome {
256263
in_progress: usize,
257264
}
258265

266+
/// Maximum consecutive poll errors per build before the poller gives
267+
/// up on that specific target and counts it as failed. Bounds the
268+
/// damage of a permanent error (deleted build, revoked PAT, 404)
269+
/// without surrendering on a single transient blip.
270+
const MAX_CONSECUTIVE_POLL_ERRORS: usize = 3;
271+
259272
async fn poll_until_complete(
260273
client: &reqwest::Client,
261274
ctx: &AdoContext,
@@ -267,6 +280,14 @@ async fn poll_until_complete(
267280
let started = Instant::now();
268281
let mut outcome = PollOutcome::default();
269282
let mut pending: Vec<PollTarget> = targets.to_vec();
283+
// Consecutive poll-error count per build. A successful poll
284+
// (Succeeded / Failed / InProgress) resets the counter via the
285+
// implicit "we don't write to the map on success" — entries are
286+
// removed when the build leaves `pending`. The counter is
287+
// independent of `next_pending` so the bookkeeping stays
288+
// round-stable.
289+
let mut consecutive_errors: std::collections::HashMap<u64, usize> =
290+
std::collections::HashMap::new();
270291

271292
println!();
272293
println!(
@@ -305,32 +326,53 @@ async fn poll_until_complete(
305326
break;
306327
}
307328
match get_build(client, ctx, auth, t.build_id).await {
308-
Ok(body) => match classify_build(&body) {
309-
BuildOutcome::InProgress => next_pending.push(*t),
310-
BuildOutcome::Succeeded => {
311-
println!("✓ build {} (definition {}) succeeded", t.build_id, t.definition_id);
312-
outcome.succeeded += 1;
329+
Ok(body) => {
330+
consecutive_errors.remove(&t.build_id);
331+
match classify_build(&body) {
332+
BuildOutcome::InProgress => next_pending.push(*t),
333+
BuildOutcome::Succeeded => {
334+
println!("✓ build {} (definition {}) succeeded", t.build_id, t.definition_id);
335+
outcome.succeeded += 1;
336+
}
337+
BuildOutcome::Failed => {
338+
let result = body
339+
.get("result")
340+
.and_then(|v| v.as_str())
341+
.unwrap_or("unknown");
342+
println!(
343+
"✗ build {} (definition {}) finished with result={}",
344+
t.build_id, t.definition_id, result
345+
);
346+
outcome.failed += 1;
347+
}
313348
}
314-
BuildOutcome::Failed => {
315-
let result = body
316-
.get("result")
317-
.and_then(|v| v.as_str())
318-
.unwrap_or("unknown");
319-
println!(
320-
"✗ build {} (definition {}) finished with result={}",
321-
t.build_id, t.definition_id, result
349+
}
350+
Err(e) => {
351+
let count = consecutive_errors.entry(t.build_id).or_insert(0);
352+
*count += 1;
353+
if *count >= MAX_CONSECUTIVE_POLL_ERRORS {
354+
eprintln!(
355+
"✗ build {} (definition {}): giving up after {} consecutive poll errors; last error: {:#}",
356+
t.build_id, t.definition_id, count, e
322357
);
358+
// Count this as a failed build so the caller's
359+
// exit code reflects the persistent error
360+
// rather than waiting out --timeout.
323361
outcome.failed += 1;
362+
consecutive_errors.remove(&t.build_id);
363+
} else {
364+
eprintln!(
365+
" warning: poll error for build {} (definition {}) (attempt {}/{}): {:#}",
366+
t.build_id,
367+
t.definition_id,
368+
count,
369+
MAX_CONSECUTIVE_POLL_ERRORS,
370+
e
371+
);
372+
// Treat as still-in-progress; we'll retry on
373+
// the next tick.
374+
next_pending.push(*t);
324375
}
325-
},
326-
Err(e) => {
327-
eprintln!(
328-
" warning: poll error for build {} (definition {}): {:#}",
329-
t.build_id, t.definition_id, e
330-
);
331-
// Treat transient poll errors as still-in-progress;
332-
// we'll retry on the next tick.
333-
next_pending.push(*t);
334376
}
335377
}
336378
}

src/status.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,18 @@ pub async fn run(opts: StatusOptions<'_>) -> Result<()> {
112112
});
113113
let matched = match_definitions_in(&definitions, &detected);
114114

115+
// Surface the "no matched fixtures" case explicitly. `status` is
116+
// read-only and intentionally non-fatal (unlike `disable` which
117+
// bails), but rendering "(no matched definitions)" without a
118+
// warning is indistinguishable from running in the wrong
119+
// directory. Mirror the existing "failed to scan" warning.
120+
if matched.is_empty() {
121+
eprintln!(
122+
"warning: no local fixtures matched any ADO definition under {}",
123+
repo_path.display()
124+
);
125+
}
126+
115127
let target_ids: HashSet<u64> = matched.iter().map(|m| m.id).collect();
116128
let mut last_runs = std::collections::HashMap::new();
117129
for id in &target_ids {

0 commit comments

Comments
 (0)