Skip to content

Commit 42fea9a

Browse files
jamesadevineCopilot
andcommitted
fix(secrets): three correctness fixes on Preview-driven discovery
1. `src/ado/discovery.rs:732` — skip-warning text overclaimed ado-aw membership. In `--all-repos` mode the per-status counters (`skipped_required_params` / `_forbidden` / `_failed`) fired for every failed Preview, including pipelines that aren't ado-aw at all. A project with hundreds of non-ado-aw pipelines that legitimately require `templateParameters` would emit "Discovery skipped N definitions whose Pipeline Preview requires templateParameters" when none of them were ado-aw consumers in the first place. We literally cannot tell which is which without successful Preview output. Consolidated the three separate `warn!` messages into one that's honest about uncertainty ("any ado-aw pipelines among them have been silently skipped"), with the per-status breakdown demoted to `debug!` for operators investigating. Counter variables renamed (`uninspectable_*`) to reflect what they actually count. 2. `src/compile/extensions/mod.rs:130` — `Path::parent()` returns `Some(Path::new(""))` for bare filenames, not `None`, so the `unwrap_or(Path::new("."))` fallback never fired for inputs like `foo.md`. Empty paths passed to `git -C "" remote get-url` behave differently from `git -C "."`. Match-arm now normalises both the `None` and empty-`Some` cases to `.`. 3. `src/secrets.rs:202` + `src/ado/discovery.rs` — `--all-repos --source` could silently exclude every strict marker when `ctx.org_name()` returned `None` or `ctx.repo_name` was empty. The existing guard only fired in the `!all_repos` branch. Extended the guard to fire for any `--source` invocation when the current `(org, repo)` is unresolvable, with an actionable error pointing the user to `--definition-ids` as the escape hatch. New test: `source_with_all_repos_bails_when_org_url_unresolvable`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6352f97 commit 42fea9a

3 files changed

Lines changed: 125 additions & 52 deletions

File tree

src/ado/discovery.rs

Lines changed: 49 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -702,10 +702,19 @@ pub async fn resolve_definitions_via_discovery(
702702
// counters while deciding inclusion — explicit two-pass form keeps
703703
// the counts honestly derived from the same iteration and makes it
704704
// obvious what ends up in the returned vec.
705-
let mut skipped_required_params = 0usize;
706-
let mut skipped_forbidden = 0usize;
707-
let mut skipped_failed = 0usize;
708-
let mut uninspectable = 0usize;
705+
//
706+
// The per-status counters (`uninspectable_required_params` /
707+
// `_forbidden` / `_failed`) tally Preview failures by reason. They
708+
// intentionally do NOT distinguish "ado-aw consumer" from
709+
// "unrelated pipeline" — Preview failed for these, so we have no
710+
// markers to tell which is which. A non-ado-aw project may have
711+
// hundreds of definitions that legitimately require
712+
// templateParameters; we can't claim any of them were ado-aw
713+
// consumers without inspecting them, so the warning text below is
714+
// written to be honest about that uncertainty.
715+
let mut uninspectable_required_params = 0usize;
716+
let mut uninspectable_forbidden = 0usize;
717+
let mut uninspectable_failed = 0usize;
709718
let mut selected: Vec<DiscoveredPipeline> = Vec::with_capacity(discovered.len());
710719

711720
for d in discovered {
@@ -716,22 +725,10 @@ pub async fn resolve_definitions_via_discovery(
716725
None => true,
717726
};
718727

719-
// Count toward skip totals only when the failure is relevant
720-
// to the requested operation:
721-
// - unfiltered: every failure is relevant (we're operating
722-
// on every ado-aw pipeline in scope);
723-
// - filtered: we can't attribute uninspectable definitions
724-
// to a specific source, so use a single combined counter.
725-
match (&d.status, normalized_filter.is_some()) {
726-
(DiscoveryStatus::UnknownRequiredParams, false) => skipped_required_params += 1,
727-
(DiscoveryStatus::UnknownForbidden, false) => skipped_forbidden += 1,
728-
(DiscoveryStatus::PreviewFailed(_), false) => skipped_failed += 1,
729-
(
730-
DiscoveryStatus::UnknownRequiredParams
731-
| DiscoveryStatus::UnknownForbidden
732-
| DiscoveryStatus::PreviewFailed(_),
733-
true,
734-
) => uninspectable += 1,
728+
match d.status {
729+
DiscoveryStatus::UnknownRequiredParams => uninspectable_required_params += 1,
730+
DiscoveryStatus::UnknownForbidden => uninspectable_forbidden += 1,
731+
DiscoveryStatus::PreviewFailed(_) => uninspectable_failed += 1,
735732
_ => {}
736733
}
737734

@@ -740,37 +737,38 @@ pub async fn resolve_definitions_via_discovery(
740737
}
741738
}
742739

743-
// Pass 2: emit warnings and convert the selected items into
744-
// `MatchedDefinition`. `discovered_to_matched` further filters out
745-
// non-actionable statuses (NotAdoAw / NotFound / UnknownForbidden /
746-
// UnknownRequiredParams / PreviewFailed); the count warnings above
747-
// are what tells the operator why those drops happened.
748-
if skipped_required_params > 0 {
749-
warn!(
750-
"Discovery skipped {skipped_required_params} definition(s) whose Pipeline Preview \
751-
requires templateParameters with no defaults. Use --definition-ids to act on them \
752-
directly.",
753-
);
754-
}
755-
if skipped_forbidden > 0 {
756-
warn!(
757-
"Discovery skipped {skipped_forbidden} definition(s) the calling identity lacks \
758-
read access to. Check your PAT or AAD permissions.",
759-
);
760-
}
761-
if skipped_failed > 0 {
762-
warn!(
763-
"Discovery skipped {skipped_failed} definition(s) whose Pipeline Preview returned \
764-
an unexpected error. Re-run with --debug to see details.",
765-
);
766-
}
767-
if uninspectable > 0
768-
&& let Some(src) = normalized_filter.as_deref()
769-
{
770-
warn!(
771-
"Discovery could not inspect {uninspectable} definition(s) (Preview failure, \
772-
forbidden, or required-parameters); any consumers of `{src}` among them have \
773-
been silently skipped. Re-run with --debug for per-definition reasons.",
740+
let uninspectable =
741+
uninspectable_required_params + uninspectable_forbidden + uninspectable_failed;
742+
743+
// Pass 2: emit a single warning that's honest about uncertainty,
744+
// and surface the per-status breakdown at debug level for
745+
// operators who want to know whether the misses were
746+
// permission-related or template-parameter-related.
747+
//
748+
// Previously we emitted three separate warn-level messages keyed
749+
// on the per-status counts (e.g. "Discovery skipped N definitions
750+
// whose Pipeline Preview requires templateParameters") — but in
751+
// `--all-repos` mode that's misleading: a project with hundreds of
752+
// non-ado-aw pipelines that legitimately require parameters would
753+
// make the operator think they'd missed N ado-aw consumers, when
754+
// none of them were ado-aw in the first place. We can't tell
755+
// which is which without successful Preview output.
756+
if uninspectable > 0 {
757+
match normalized_filter.as_deref() {
758+
Some(src) => warn!(
759+
"Discovery could not inspect {uninspectable} definition(s) (Preview failure, \
760+
forbidden, or required-parameters); any consumers of `{src}` among them have \
761+
been silently skipped. Re-run with --debug for per-definition reasons.",
762+
),
763+
None => warn!(
764+
"Discovery could not inspect {uninspectable} definition(s) (Preview failure, \
765+
forbidden, or required-parameters); any ado-aw pipelines among them have been \
766+
silently skipped. Re-run with --debug for per-definition reasons.",
767+
),
768+
}
769+
debug!(
770+
"Uninspectable breakdown: {uninspectable_required_params} required-parameters, \
771+
{uninspectable_forbidden} forbidden, {uninspectable_failed} other Preview errors.",
774772
);
775773
}
776774

src/compile/extensions/mod.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,17 @@ impl<'a> CompileContext<'a> {
123123
/// context from the git remote in the directory containing `input_path`.
124124
/// Returns an error if the engine identifier is unsupported.
125125
pub async fn new(front_matter: &'a FrontMatter, input_path: &'a Path) -> Result<Self> {
126-
let compile_dir = input_path.parent().unwrap_or(Path::new("."));
126+
// `Path::parent()` is subtle: for a bare filename like `foo.md`
127+
// it returns `Some(Path::new(""))` rather than `None`, so the
128+
// `unwrap_or(Path::new("."))` fallback wouldn't catch it. An
129+
// empty path passed to `git -C ""` behaves differently from
130+
// `git -C "."` (some platforms reject it, others quietly use
131+
// the parent process's cwd), so we normalise both the
132+
// `None` and empty-`Some` cases to `.`.
133+
let compile_dir = match input_path.parent() {
134+
Some(p) if !p.as_os_str().is_empty() => p,
135+
_ => Path::new("."),
136+
};
127137
let engine = engine::get_engine(front_matter.engine.engine_id())?;
128138
let ado_context = Self::infer_ado_context(compile_dir).await;
129139
Ok(Self {

src/secrets.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,29 @@ async fn resolve_for_command(
214214
);
215215
}
216216

217+
// `--source` requires an identifiable current (org, repo) so
218+
// the marker-origin filter in discovery can disambiguate
219+
// same-named source paths across repos. Without it, a strict
220+
// marker (one carrying `org` / `repo`) would silently fail the
221+
// origin check and the operator would see "no pipelines
222+
// matched" with no explanation. The `!all_repos` guard above
223+
// covers the missing-`repo_name` case for current-repo scope;
224+
// here we also catch `--all-repos --source` paired with a
225+
// missing or malformed `org_url` (e.g. `org_name()` resolves
226+
// to `None`).
227+
if source_filter.is_some()
228+
&& (ado_ctx.org_name().is_none() || ado_ctx.repo_name.is_empty())
229+
{
230+
anyhow::bail!(
231+
"--source needs the current repository's Azure DevOps org and repo to \
232+
disambiguate same-named source paths across the project, but neither \
233+
could be resolved from `{}`.\n\
234+
Run from a checkout of an ADO repo, or use --definition-ids to act on \
235+
specific pipelines directly.",
236+
repo_path.display()
237+
);
238+
}
239+
217240
let scope = if all_repos {
218241
DiscoveryScope::AllRepos
219242
} else {
@@ -746,6 +769,48 @@ mod tests {
746769
);
747770
}
748771

772+
#[tokio::test]
773+
async fn source_with_all_repos_bails_when_org_url_unresolvable() {
774+
// `--all-repos --source` still needs the current (org, repo)
775+
// to disambiguate same-named source paths via the marker's
776+
// origin fields. If `org_url` doesn't yield an org slug (or
777+
// `repo_name` is empty), the marker-origin filter would
778+
// silently exclude every strict marker — surface a targeted
779+
// error instead.
780+
//
781+
// Empty `org_url` is the realistic failure mode: a hand-built
782+
// AdoContext from `--org "" --project p` or a corrupted ADO
783+
// remote that parsed past `parse_ado_remote` would land here.
784+
let ctx = AdoContext {
785+
org_url: String::new(), // org_name() resolves to None
786+
project: "p".to_string(),
787+
repo_name: "some-repo".to_string(),
788+
};
789+
let auth = AdoAuth::Pat("token".to_string());
790+
let client = reqwest::Client::builder()
791+
.build()
792+
.expect("client builds");
793+
let tmp = tempfile::tempdir().unwrap();
794+
795+
let err = resolve_for_command(
796+
&client,
797+
&ctx,
798+
&auth,
799+
None,
800+
true, // --all-repos
801+
Some("agents/foo.md"),
802+
tmp.path(),
803+
)
804+
.await
805+
.expect_err("expected bail");
806+
807+
let msg = format!("{err}");
808+
assert!(
809+
msg.contains("--source needs the current repository's"),
810+
"error should explain the root cause; got: {msg}"
811+
);
812+
}
813+
749814
#[test]
750815
fn set_preserves_other_variables() {
751816
let def = serde_json::json!({

0 commit comments

Comments
 (0)