From 9eeb815bdcc2864c8f77c8ca7642f39ac58d5b52 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:36:11 +0000 Subject: [PATCH] feat(task): add affected JSON output --- TASK_CACHE_PARITY.md | 2 +- docs/cli/run.md | 4 ++ docs/cli/tasks/run.md | 4 ++ docs/tasks/monorepo.md | 7 +++ e2e/tasks/test_task_affected | 37 ++++++++++++++ man/man1/mise.1 | 6 +++ mise.usage.kdl | 2 + src/cli/bootstrap.rs | 1 + src/cli/mod.rs | 1 + src/cli/run.rs | 96 +++++++++++++++++++++++++++++++++++- 10 files changed, 157 insertions(+), 3 deletions(-) diff --git a/TASK_CACHE_PARITY.md b/TASK_CACHE_PARITY.md index f83f637ec33..85a0e794cb4 100644 --- a/TASK_CACHE_PARITY.md +++ b/TASK_CACHE_PARITY.md @@ -73,7 +73,7 @@ outside this tracker. - [x] Allow affected selection to feed existing task patterns, dependency expansion, scheduling, and caching - [x] Show why each project and task was considered affected -- [ ] Add machine-readable affected-project and affected-task output +- [x] Add machine-readable affected-project and affected-task output ## Local cache parity diff --git a/docs/cli/run.md b/docs/cli/run.md index 9ccd2b15265..013fe482117 100644 --- a/docs/cli/run.md +++ b/docs/cli/run.md @@ -55,6 +55,10 @@ Explain why projects and tasks were selected by --affected Git head revision for --affected Defaults to MISE_AFFECTED_HEAD, CI metadata, or HEAD +### `--affected-json` + +Output affected projects and tasks as JSON without running tasks + ### `-c --continue-on-error` Continue running tasks even if one fails diff --git a/docs/cli/tasks/run.md b/docs/cli/tasks/run.md index b262da52071..37e8ca8bca6 100644 --- a/docs/cli/tasks/run.md +++ b/docs/cli/tasks/run.md @@ -69,6 +69,10 @@ Explain why projects and tasks were selected by --affected Git head revision for --affected Defaults to MISE_AFFECTED_HEAD, CI metadata, or HEAD +### `--affected-json` + +Output affected projects and tasks as JSON without running tasks + ### `-c --continue-on-error` Continue running tasks even if one fails diff --git a/docs/tasks/monorepo.md b/docs/tasks/monorepo.md index be087083c31..aa8dfa2b556 100644 --- a/docs/tasks/monorepo.md +++ b/docs/tasks/monorepo.md @@ -374,6 +374,9 @@ mise run --affected build # Inspect the selection while preserving normal dry-run behavior mise run --affected --affected-explain --dry-run build +# Emit the selection as JSON without running tasks +mise run --affected --affected-json build + # Compare explicit revisions mise run --affected --affected-base origin/main --affected-head HEAD test ``` @@ -384,6 +387,10 @@ It also lists the task-pattern matches associated with those projects. Normal ta expanded afterward, so a selected task can still run a required prerequisite from an unchanged project. +`--affected-json` emits the same pre-expansion selection without running tasks. Its stable JSON +object contains the base and head revisions, affected projects with their roots and reasons, and +task-pattern matches with their associated project IDs. + The revision defaults are `HEAD~1` and `HEAD` locally. `MISE_AFFECTED_BASE` and `MISE_AFFECTED_HEAD` override them. GitHub Actions and GitLab merge-request metadata provide CI defaults when those variables are not set; explicit CLI options take highest precedence. diff --git a/e2e/tasks/test_task_affected b/e2e/tasks/test_task_affected index f7a7e7728bf..32961723732 100644 --- a/e2e/tasks/test_task_affected +++ b/e2e/tasks/test_task_affected @@ -68,6 +68,43 @@ git commit -qm initial echo changed >>crates/core/src/lib.rs git add . git commit -qm core-change +assert_json "mise run --affected --affected-json build" '{ + "base": "HEAD~1", + "head": "HEAD", + "projects": [ + { + "id": "cargo:app", + "root": "crates/app", + "reasons": [ + { + "type": "dependent", + "dependency": "cargo:core" + } + ] + }, + { + "id": "cargo:core", + "root": "crates/core", + "reasons": [ + { + "type": "changed-path", + "path": "crates/core/src/lib.rs" + } + ] + } + ], + "tasks": [ + { + "name": "//crates/app:build", + "projects": ["cargo:app"] + }, + { + "name": "//crates/core:build", + "projects": ["cargo:core"] + } + ] +}' +assert "test ! -e affected.log" output=$(mise run --affected --affected-explain --jobs 1 build) assert_contains "echo \"$output\"" "Affected projects (HEAD~1...HEAD):" assert_contains "echo \"$output\"" "changed path: crates/core/src/lib.rs" diff --git a/man/man1/mise.1 b/man/man1/mise.1 index b8b1c8c73a9..974e179c14f 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -3039,6 +3039,9 @@ Explain why projects and tasks were selected by \-\-affected Git head revision for \-\-affected Defaults to MISE_AFFECTED_HEAD, CI metadata, or HEAD .TP +\fB\-\-affected\-json\fR +Output affected projects and tasks as JSON without running tasks +.TP \fB\-c, \-\-continue\-on\-error\fR Continue running tasks even if one fails .TP @@ -3848,6 +3851,9 @@ Explain why projects and tasks were selected by \-\-affected Git head revision for \-\-affected Defaults to MISE_AFFECTED_HEAD, CI metadata, or HEAD .TP +\fB\-\-affected\-json\fR +Output affected projects and tasks as JSON without running tasks +.TP \fB\-c, \-\-continue\-on\-error\fR Continue running tasks even if one fails .TP diff --git a/mise.usage.kdl b/mise.usage.kdl index f024007e836..35aaa2e43b2 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -3059,6 +3059,7 @@ Defaults to MISE_AFFECTED_HEAD, CI metadata, or HEAD """# { arg } + flag --affected-json help="Output affected projects and tasks as JSON without running tasks" flag "-c --continue-on-error" help="Continue running tasks even if one fails" flag "-C --cd" help="Change to this directory before executing the command" { arg @@ -3908,6 +3909,7 @@ Defaults to MISE_AFFECTED_HEAD, CI metadata, or HEAD """# { arg } + flag --affected-json help="Output affected projects and tasks as JSON without running tasks" flag "-c --continue-on-error" help="Continue running tasks even if one fails" flag "-C --cd" help="Change to this directory before executing the command" { arg diff --git a/src/cli/bootstrap.rs b/src/cli/bootstrap.rs index 6e71756e8b6..751610024fe 100644 --- a/src/cli/bootstrap.rs +++ b/src/cli/bootstrap.rs @@ -903,6 +903,7 @@ impl Bootstrap { affected_base: None, affected_head: None, affected_explain: false, + affected_json: false, cd: None, continue_on_error: false, dry_run: self.dry_run, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c194f2555ef..b3a004eadf5 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -779,6 +779,7 @@ impl Cli { affected_base: None, affected_head: None, affected_explain: false, + affected_json: false, cd: self.cd, continue_on_error: self.continue_on_error, dry_run: self.dry_run, diff --git a/src/cli/run.rs b/src/cli/run.rs index af69fec1df1..b978858a688 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -25,6 +25,7 @@ use clap::{CommandFactory, ValueHint}; use eyre::{Result, bail, eyre}; use futures_util::FutureExt; use itertools::Itertools; +use serde::Serialize; use std::panic::AssertUnwindSafe; use tokio::sync::Mutex; @@ -84,7 +85,12 @@ pub struct Run { pub affected_base: Option, /// Explain why projects and tasks were selected by --affected - #[clap(long, requires = "affected", verbatim_doc_comment)] + #[clap( + long, + requires = "affected", + conflicts_with = "affected_json", + verbatim_doc_comment + )] pub affected_explain: bool, /// Git head revision for --affected @@ -92,6 +98,15 @@ pub struct Run { #[clap(long, requires = "affected", value_name = "REV", verbatim_doc_comment)] pub affected_head: Option, + /// Output affected projects and tasks as JSON without running tasks + #[clap( + long, + requires = "affected", + conflicts_with = "affected_explain", + verbatim_doc_comment + )] + pub affected_json: bool, + /// Continue running tasks even if one fails #[clap(long, short = 'c', verbatim_doc_comment)] pub continue_on_error: bool, @@ -293,6 +308,7 @@ async fn get_affected_task_list( base: Option<&str>, head: Option<&str>, explain: bool, + json: bool, ) -> Result> { Settings::get().ensure_experimental("affected tasks")?; let workspace_root = config @@ -365,12 +381,84 @@ async fn get_affected_task_list( .map(crate::file::desymlink_path) .is_some_and(|root| affected_roots.contains(&root)) }); - if explain { + if json { + display_affected_json(&revisions, &workspace_root, &graph, &affected, &tasks)?; + } else if explain { display_affected_explanation(&revisions, &workspace_root, &graph, &affected, &tasks)?; } Ok(tasks) } +#[derive(Serialize)] +struct AffectedSelectionOutput<'a> { + base: &'a str, + head: &'a str, + projects: Vec>, + tasks: Vec>, +} + +#[derive(Serialize)] +struct AffectedProjectOutput<'a> { + id: &'a crate::task::workspace::ProjectId, + root: &'a std::path::Path, + reasons: &'a BTreeSet, +} + +#[derive(Serialize)] +struct AffectedTaskOutput<'a> { + name: &'a str, + projects: Vec<&'a crate::task::workspace::ProjectId>, +} + +fn display_affected_json( + revisions: &crate::task::workspace::git::WorkspaceGitRevisions, + workspace_root: &std::path::Path, + graph: &crate::task::workspace::WorkspaceProjectGraph, + affected: &crate::task::workspace::AffectedProjects, + tasks: &[Task], +) -> Result<()> { + let mut projects_by_root = BTreeMap::>::new(); + let projects = affected + .projects() + .map(|(id, reasons)| { + let project = graph.get(id).expect("affected project exists in graph"); + projects_by_root + .entry(crate::file::desymlink_path( + &workspace_root.join(&project.root), + )) + .or_default() + .push(id); + AffectedProjectOutput { + id, + root: &project.root, + reasons, + } + }) + .collect(); + let mut tasks = tasks + .iter() + .map(|task| AffectedTaskOutput { + name: &task.display_name, + projects: task + .config_root + .as_deref() + .map(crate::file::desymlink_path) + .and_then(|root| projects_by_root.get(&root)) + .cloned() + .unwrap_or_default(), + }) + .collect::>(); + tasks.sort_by(|left, right| left.name.cmp(right.name)); + let output = AffectedSelectionOutput { + base: &revisions.base, + head: &revisions.head, + projects, + tasks, + }; + miseprintln!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} + fn display_affected_explanation( revisions: &crate::task::workspace::git::WorkspaceGitRevisions, workspace_root: &std::path::Path, @@ -532,11 +620,15 @@ impl Run { self.affected_base.as_deref(), self.affected_head.as_deref(), self.affected_explain, + self.affected_json, ) .await? } else { get_task_lists(&config, &args, true, self.skip_deps).await? }; + if self.affected_json { + return Ok(()); + } // Args after -- go directly to tasks (no prefix). They are also // recorded on `trailing_args` so the task renderer can detect