feat(apply): cross-repo blast-radius in the plan preview

§7 M3 (part 2): planning a change to a GROUP node now reports the descendant
apps owned by OTHER projects that the change fans out to — the cross-repo
signal an operator needs before touching shared config.

- OwnershipPreview gains `blast_radius: Vec<ProjectImpact>`; `group_blast_radius`
  walks the group's subtree (recursive CTE) and groups descendant apps by their
  nearest-claimed-ancestor project, excluding the planning project. Ancestor
  resolution is memoized per group (one walk per distinct descendant group, not
  per app).
- `pic plan` renders `blast_radius` rows (mode-agnostic).
- the apply_ownership journey gains a plan-preview case pinning both the
  ownership annotation (claim / conflict) and the blast radius (two other
  projects' apps surfaced) end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 21:17:15 +02:00
parent b4eb226d20
commit 30fe160f16
4 changed files with 209 additions and 7 deletions

View File

@@ -1548,12 +1548,21 @@ pub struct ChangeDto {
}
/// §7 M3 plan preview: `action` ∈ claim/owned/conflict/unclaimed; `owner` is
/// the current owner's slug (for owned/conflict).
/// the current owner's slug (for owned/conflict); `blast_radius` (M3.2) lists
/// other projects' apps a group change fans out to.
#[derive(Debug, Deserialize)]
pub struct OwnershipPreviewDto {
pub action: String,
#[serde(default)]
pub owner: Option<String>,
#[serde(default)]
pub blast_radius: Vec<ProjectImpactDto>,
}
#[derive(Debug, Deserialize)]
pub struct ProjectImpactDto {
pub project: String,
pub apps: u32,
}
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined

View File

@@ -74,6 +74,15 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
o.owner.clone().unwrap_or_default(),
String::new(),
]);
for b in &o.blast_radius {
table.row([
node.clone(),
"blast_radius".to_string(),
b.project.clone(),
format!("{} app(s)", b.apps),
String::new(),
]);
}
}
let groups: [(&str, &Vec<ChangeDto>); 8] = [
("script", &n.scripts),
@@ -203,6 +212,14 @@ fn render(plan: &PlanDto, mode: OutputMode) {
o.owner.clone().unwrap_or_default(),
String::new(),
]);
for b in &o.blast_radius {
table.row([
"blast_radius".to_string(),
b.project.clone(),
format!("{} app(s)", b.apps),
String::new(),
]);
}
}
let groups: [(&str, &Vec<ChangeDto>); 8] = [
("script", &plan.scripts),

View File

@@ -291,3 +291,107 @@ fn attach_point_ceiling_bounds_the_subtree() {
"a sibling subtree is outside the attach point"
);
}
/// §7 M3 — `pic plan` previews the ownership outcome (claim / conflict) and the
/// cross-repo blast radius (descendant apps owned by other projects) BEFORE any
/// apply.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn plan_previews_ownership_and_blast_radius() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
// acme (root) → team_a, team_b; an app under each, each team claimed by a
// DIFFERENT project.
let acme = common::unique_slug("br-acme");
let team_a = common::unique_slug("br-a");
let team_b = common::unique_slug("br-b");
let app_a = common::unique_slug("br-app-a");
let app_b = common::unique_slug("br-app-b");
let plat = common::unique_slug("plat");
let teamb = common::unique_slug("teamb");
let platform = common::unique_slug("platform");
let _g0 = GroupGuard::new(&env.url, &env.token, &acme);
let _g1 = GroupGuard::new(&env.url, &env.token, &team_a);
let _g2 = GroupGuard::new(&env.url, &env.token, &team_b);
let _a1 = AppGuard::new(&env.url, &env.token, &app_a);
let _a2 = AppGuard::new(&env.url, &env.token, &app_b);
for (slug, parent) in [
(&acme, None),
(&team_a, Some(&acme)),
(&team_b, Some(&acme)),
] {
let mut c = common::pic_as(&env);
c.args(["groups", "create", slug]);
if let Some(p) = parent {
c.args(["--parent", p]);
}
c.assert().success();
}
common::pic_as(&env)
.args(["apps", "create", &app_a, "--group", &team_a])
.assert()
.success();
common::pic_as(&env)
.args(["apps", "create", &app_b, "--group", &team_b])
.assert()
.success();
// Claim team_a by `plat`, team_b by `teamb` (empty group applies).
let dir = manifest_dir();
let claim = |project: &str, group: &str| {
let m = format!(
"[project]\nslug = \"{project}\"\n\n[group]\nslug = \"{group}\"\nname = \"G\"\n"
);
fs::write(dir.path().join("picloud.toml"), m).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.assert()
.success();
};
claim(&plat, &team_a);
claim(&teamb, &team_b);
// Plan acme with project `platform`: acme is unclaimed → action `claim`; the
// blast radius lists the OTHER projects' descendant apps (plat + teamb).
fs::write(
dir.path().join("picloud.toml"),
format!(
"[project]\nslug = \"{platform}\"\n\n[group]\nslug = \"{acme}\"\nname = \"Acme\"\n"
),
)
.unwrap();
let out = common::pic_as(&env)
.args(["plan", "--file"])
.arg(dir.path().join("picloud.toml"))
.output()
.expect("plan acme");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("claim"),
"acme is unclaimed → the preview must show `claim`:\n{stdout}"
);
assert!(
stdout.contains("blast_radius") && stdout.contains(&plat) && stdout.contains(&teamb),
"the blast radius must list the other projects' apps (plat + teamb):\n{stdout}"
);
// Plan team_a with `platform`: it is owned by `plat` → action `conflict`.
fs::write(
dir.path().join("picloud.toml"),
format!("[project]\nslug = \"{platform}\"\n\n[group]\nslug = \"{team_a}\"\nname = \"A\"\n"),
)
.unwrap();
let out = common::pic_as(&env)
.args(["plan", "--file"])
.arg(dir.path().join("picloud.toml"))
.output()
.expect("plan team_a");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("conflict") && stdout.contains(&plat),
"planning a foreign-owned group must preview a conflict naming the owner:\n{stdout}"
);
}