feat(cli): pic projects ls + §7 M3/track-complete docs

§7 M3 (part 3) — the ownership track becomes inspectable, completing M1–M3.

- server: GET /api/v1/admin/projects (projects_api) lists every project + its
  owned-group count (projects repo list_with_counts, one GROUP BY); behind the
  /admin middleware, like the groups list.
- CLI: pic projects ls (cmds/projects.rs + client projects_list); the
  apply_ownership plan-preview journey now also asserts projects ls (plat owns
  exactly 1 group; teamb listed).
- docs: design §7 + CLAUDE.md record M3 shipped and the whole §7 multi-repo
  ownership track (M1 claim · M2 attach ceiling · M3 preview + projects ls)
  COMPLETE; deferred = structural-divergence + declarative tree shape + per-env
  approval gating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 21:26:10 +02:00
parent 30fe160f16
commit f673922d89
11 changed files with 244 additions and 29 deletions

View File

@@ -174,6 +174,16 @@ impl Client {
decode(resp).await
}
/// `GET /api/v1/admin/projects` — every §7 project + its owned-group count.
/// Backs `pic projects ls`.
pub async fn projects_list(&self) -> Result<Vec<ProjectDto>> {
let resp = self
.request(Method::GET, "/api/v1/admin/projects")
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children.
pub async fn groups_get(&self, ident: &str) -> Result<GroupDetailDto> {
let ident = seg(ident);
@@ -1708,6 +1718,17 @@ pub struct GroupListItem {
pub owner: Option<String>,
}
/// One row of `pic projects ls` (§7 M3): a project + how many group nodes it
/// owns.
#[derive(Debug, Deserialize)]
pub struct ProjectDto {
pub slug: String,
pub name: String,
#[serde(default)]
pub owned_groups: i64,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
pub struct CreateRouteBody<'a> {
pub host_kind: HostKind,

View File

@@ -16,6 +16,7 @@ pub mod logout;
pub mod logs;
pub mod members;
pub mod plan;
pub mod projects;
pub mod pull;
pub mod queues;
pub mod routes;

View File

@@ -0,0 +1,29 @@
//! `pic projects` — read-only view of §7 multi-repo ownership projects.
//!
//! A project is the repo-root that declaratively owns a slice of the group
//! tree. Ownership is CLAIMED by `pic apply` (first apply with a `[project]`
//! slug registers it); this command only lists what exists.
use anyhow::Result;
use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
/// `pic projects ls` — every project + how many group nodes it owns.
pub async fn ls(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let projects = client.projects_list().await?;
let mut table = Table::new(["slug", "name", "owned_groups", "created_at"]);
for p in &projects {
table.row([
p.slug.clone(),
p.name.clone(),
p.owned_groups.to_string(),
p.created_at.to_rfc3339(),
]);
}
table.print(mode);
Ok(())
}

View File

@@ -58,6 +58,12 @@ enum Cmd {
cmd: GroupsCmd,
},
/// §7 multi-repo ownership projects (read-only).
Projects {
#[command(subcommand)]
cmd: ProjectsCmd,
},
/// Script management.
Scripts {
#[command(subcommand)]
@@ -490,6 +496,12 @@ enum AppsCmd {
},
}
#[derive(Subcommand)]
enum ProjectsCmd {
/// List all projects + how many group nodes each owns.
Ls,
}
#[derive(Subcommand)]
enum GroupsCmd {
/// List all groups (flat).
@@ -1509,6 +1521,9 @@ async fn main() -> ExitCode {
cmd: DomainsCmd::Rm { app, domain_id },
},
} => cmds::apps_domains::rm(&app, &domain_id).await,
Cmd::Projects {
cmd: ProjectsCmd::Ls,
} => cmds::projects::ls(mode).await,
Cmd::Groups { cmd: GroupsCmd::Ls } => cmds::groups::ls(mode).await,
Cmd::Groups {
cmd: GroupsCmd::Tree,

View File

@@ -354,6 +354,31 @@ fn plan_previews_ownership_and_blast_radius() {
claim(&plat, &team_a);
claim(&teamb, &team_b);
// `pic projects ls` lists both registered projects with their owned-group
// counts (plat owns exactly team_a).
let projects = String::from_utf8(
common::pic_as(&env)
.args(["projects", "ls"])
.output()
.unwrap()
.stdout,
)
.unwrap();
let plat_row = projects
.lines()
.map(common::cells)
.find(|c| c.first() == Some(&plat.as_str()))
.unwrap_or_else(|| panic!("plat not in projects ls:\n{projects}"));
assert_eq!(
plat_row.get(2).copied(),
Some("1"),
"plat owns exactly one group:\n{projects}"
);
assert!(
projects.contains(teamb.as_str()),
"teamb must be listed:\n{projects}"
);
// Plan acme with project `platform`: acme is unclaimed → action `claim`; the
// blast radius lists the OTHER projects' descendant apps (plat + teamb).
fs::write(