From cf57cf93e9fd3439535f421b7bff98c708bde3fd Mon Sep 17 00:00:00 2001 From: Sara Date: Mon, 26 Oct 2026 14:35:15 -0400 Subject: [PATCH 1/4] Add team listing and project team moves on CLI and MCP - list teams (search, pagination, or --project for one project's teams) - list projects --team / --no-team (GET /v2/projects?teamId=) - add/remove/move verbs over PUT /teams/:id/projects. Move = add to the target, then remove from the source: a failed add removes nothing; a failed remove reports the projects are now on both teams. Only add and remove are ever sent (replace wipes the team). Revision suffixes are stripped, since one would 403 the whole add batch. - Project.teams is now Vec: GET /v2/projects sends team objects, so any project on a team failed to deserialize as Vec. - Mock server gains the team routes; e2e covers list, filter, add, remove, move, move with a failed add, and suffix stripping. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fvv9sgYUtfaDtFUMPc5Upw --- src/bin/fossapi.rs | 29 ++- src/cli/mod.rs | 23 ++- src/lib.rs | 7 + src/mcp/server.rs | 85 ++++++++- src/mock_server/CLAUDE.md | 4 + src/mock_server/fixtures.rs | 20 ++- src/mock_server/handlers/mod.rs | 2 + src/mock_server/handlers/projects.rs | 22 ++- src/mock_server/handlers/teams.rs | 162 +++++++++++++++++ src/mock_server/server.rs | 7 + src/mock_server/state.rs | 11 +- src/models/mod.rs | 2 + src/models/project.rs | 9 +- src/models/team.rs | 258 +++++++++++++++++++++++++++ src/ops/list.rs | 77 +++++++- src/ops/mod.rs | 10 +- src/ops/team.rs | 108 +++++++++++ src/output.rs | 17 ++ tests/cli_args.rs | 41 ++++- tests/e2e_mock_server.rs | 211 +++++++++++++++++++++- tests/parity.rs | 39 +++- 21 files changed, 1117 insertions(+), 27 deletions(-) create mode 100644 src/mock_server/handlers/teams.rs create mode 100644 src/models/team.rs create mode 100644 src/ops/team.rs diff --git a/src/bin/fossapi.rs b/src/bin/fossapi.rs index a8335b8..ec6d753 100644 --- a/src/bin/fossapi.rs +++ b/src/bin/fossapi.rs @@ -7,7 +7,10 @@ use clap::Parser; use fossapi::cli::{Cli, Command}; -use fossapi::ops::{run_get, run_ignore, run_list, run_unignore, run_update, ListOutput}; +use fossapi::ops::{ + run_add, run_get, run_ignore, run_list, run_move, run_remove, run_unignore, run_update, + ListOutput, +}; use fossapi::{FossaClient, Page, PrettyPrint, Project, Snippet, SnippetLocation, SnippetPath}; use serde::Serialize; use std::process::ExitCode; @@ -57,6 +60,9 @@ async fn run(client: &FossaClient, cli: Cli) -> fossapi::Result<()> { let output = run_unignore(client, command).await?; output_single(&output, cli.json) } + Command::Add { command } => output_single(&run_add(client, command).await?, cli.json), + Command::Remove { command } => output_single(&run_remove(client, command).await?, cli.json), + Command::Move { command } => output_single(&run_move(client, command).await?, cli.json), Command::Mcp { verbose } => handle_mcp(client, verbose).await, } } @@ -99,6 +105,7 @@ fn output_single(item: &T, json: bool) -> fossapi::R fn output_list(output: &ListOutput, json: bool) -> fossapi::Result<()> { match output { ListOutput::Projects(page) => output_page(page, json, |p| ProjectRow::from(p)), + ListOutput::Teams(page) => output_page(page, json, |t| TeamRow::from(t)), ListOutput::Issues(page) => output_page(page, json, |i| IssueRow::from(i)), ListOutput::Dependencies(page) => output_page(page, json, |d| DependencyRow::from(d)), ListOutput::Revisions(page) => output_page(page, json, |r| RevisionRow::from(r)), @@ -168,6 +175,26 @@ impl From<&Project> for ProjectRow { } } +#[derive(Tabled)] +struct TeamRow { + id: u64, + name: String, + projects: String, +} + +impl From<&fossapi::Team> for TeamRow { + fn from(t: &fossapi::Team) -> Self { + Self { + id: t.id, + name: t.name.clone(), + projects: t + .team_projects_count + .map(|c| c.to_string()) + .unwrap_or_default(), + } + } +} + #[derive(Tabled)] struct IssueRow { id: u64, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6b3e683..725a3ad 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -7,7 +7,10 @@ use clap::{Parser, Subcommand}; -pub use crate::ops::{GetCommand, IgnoreCommand, ListCommand, UnignoreCommand, UpdateCommand}; +pub use crate::ops::{ + AddCommand, GetCommand, IgnoreCommand, ListCommand, MoveCommand, RemoveCommand, + UnignoreCommand, UpdateCommand, +}; /// FOSSA API command-line interface. #[derive(Parser, Debug)] @@ -54,6 +57,24 @@ pub enum Command { command: UnignoreCommand, }, + /// Add projects to a team (keeps their other teams). + Add { + #[command(subcommand)] + command: AddCommand, + }, + + /// Remove projects from a team. + Remove { + #[command(subcommand)] + command: RemoveCommand, + }, + + /// Move projects from one team to another (add, then remove). + Move { + #[command(subcommand)] + command: MoveCommand, + }, + /// Run the MCP server on stdio. Mcp { /// Enable verbose (debug) logging. diff --git a/src/lib.rs b/src/lib.rs index 9bee59b..b5b2c1b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -122,9 +122,16 @@ pub use models::{ SnippetMatchDetails, SnippetPath, SnippetQuery, + // Team types + Team, + TeamMove, + TeamProjects, }; // Re-export convenience functions +pub use models::{ + add_team_projects, get_project_teams, get_teams_page, move_team_projects, remove_team_projects, +}; pub use models::{get_dependencies, get_dependencies_page}; pub use models::{get_issues, get_issues_page, get_project_issues}; pub use models::{get_revision, get_revisions, get_revisions_page}; diff --git a/src/mcp/server.rs b/src/mcp/server.rs index f162209..ef6173d 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -16,8 +16,9 @@ use std::sync::Arc; use crate::{ ops::{ - run_get, run_ignore, run_list, run_unignore, run_update, GetCommand, IgnoreCommand, - ListCommand, UnignoreCommand, UpdateCommand, + run_add, run_get, run_ignore, run_list, run_move, run_remove, run_unignore, run_update, + AddCommand, GetCommand, IgnoreCommand, ListCommand, MoveCommand, RemoveCommand, + UnignoreCommand, UpdateCommand, }, FossaClient, FossaError, }; @@ -30,7 +31,7 @@ use crate::{ /// # Tools /// /// The server exposes one tool per CLI verb — `get`, `list`, `update`, -/// `ignore`, and `unignore` — and each tool's input schema is generated from the same +/// `ignore`, `unignore`, `add`, `remove`, and `move` — and each tool's input schema is generated from the same /// [`crate::ops`] enum that the CLI parses into, so the two surfaces /// always expose the same operations with the same parameters. /// @@ -87,7 +88,10 @@ impl FossaServer { ), Tool::new( "list", - "List FOSSA entities. entity values: projects (paginated), issues \ + "List FOSSA entities. entity values: projects (paginated; optional \ + team filters to one numeric team id, no_team to projects on no team), \ + teams (paginated, count max 50; optional search by name, or project \ + locator for the teams that project is on), issues \ (category required: vulnerability, licensing, quality), dependencies \ (revision locator required), revisions (project locator required), \ snippets (revision locator; one row per matched OSS package), \ @@ -120,6 +124,28 @@ impl FossaServer { nothing on the issue is ignored. Requires a full API token.", Self::schema::(), ), + Tool::new( + "add", + "Add FOSSA projects to a team. entity values: project (locators \ + list, team numeric id). Projects keep their other teams. \ + All-or-nothing: refused if the token lacks Edit on any project.", + Self::schema::(), + ), + Tool::new( + "remove", + "Remove FOSSA projects from a team. entity values: project \ + (locators list, team numeric id).", + Self::schema::(), + ), + Tool::new( + "move", + "Move FOSSA projects from one team to another. entity values: \ + project (locators list, from and to numeric team ids). Adds to \ + the target team, then removes from the source team; if the add \ + fails nothing is removed, and if the remove fails the error says \ + the projects are now on both teams.", + Self::schema::(), + ), ] } @@ -204,6 +230,30 @@ impl FossaServer { Self::to_result(&output) } + /// Handle the `add` tool. + pub async fn handle_add(&self, command: AddCommand) -> Result { + let output = run_add(&self.client, command) + .await + .map_err(Self::to_mcp_error)?; + Self::to_result(&output) + } + + /// Handle the `remove` tool. + pub async fn handle_remove(&self, command: RemoveCommand) -> Result { + let output = run_remove(&self.client, command) + .await + .map_err(Self::to_mcp_error)?; + Self::to_result(&output) + } + + /// Handle the `move` tool. + pub async fn handle_move(&self, command: MoveCommand) -> Result { + let output = run_move(&self.client, command) + .await + .map_err(Self::to_mcp_error)?; + Self::to_result(&output) + } + /// Handle the `unignore` tool. pub async fn handle_unignore( &self, @@ -290,6 +340,21 @@ impl ServerHandler for FossaServer { .map_err(|e| Self::describe_args_error(e, &args))?; self.handle_unignore(command).await } + "add" => { + let command: AddCommand = serde_json::from_value(args.clone()) + .map_err(|e| Self::describe_args_error(e, &args))?; + self.handle_add(command).await + } + "remove" => { + let command: RemoveCommand = serde_json::from_value(args.clone()) + .map_err(|e| Self::describe_args_error(e, &args))?; + self.handle_remove(command).await + } + "move" => { + let command: MoveCommand = serde_json::from_value(args.clone()) + .map_err(|e| Self::describe_args_error(e, &args))?; + self.handle_move(command).await + } other => Err(McpError::invalid_params( format!("Unknown tool: {other}"), None, @@ -324,7 +389,10 @@ mod tests { .iter() .map(|t| t.name.to_string()) .collect(); - assert_eq!(names, ["get", "list", "update", "ignore", "unignore"]); + assert_eq!( + names, + ["get", "list", "update", "ignore", "unignore", "add", "remove", "move"] + ); } #[test] @@ -334,6 +402,9 @@ mod tests { assert!(!FossaServer::schema::().is_empty()); assert!(!FossaServer::schema::().is_empty()); assert!(!FossaServer::schema::().is_empty()); + assert!(!FossaServer::schema::().is_empty()); + assert!(!FossaServer::schema::().is_empty()); + assert!(!FossaServer::schema::().is_empty()); } #[test] @@ -386,6 +457,8 @@ mod tests { let result = server .handle_list(ListCommand::Projects(ListProjectsParams { + team: None, + no_team: false, pagination: PageArgs::default(), })) .await @@ -507,6 +580,8 @@ mod tests { let _ = server .handle_list(ListCommand::Projects(ListProjectsParams { + team: None, + no_team: false, pagination: PageArgs { page: Some(1), count: Some(200), diff --git a/src/mock_server/CLAUDE.md b/src/mock_server/CLAUDE.md index 9967f4c..f4c01d9 100644 --- a/src/mock_server/CLAUDE.md +++ b/src/mock_server/CLAUDE.md @@ -19,6 +19,7 @@ mock_server/ ├── projects.rs # GET/PUT /projects/:locator, GET /v2/projects ├── revisions.rs # GET /revisions/:locator, GET /projects/:locator/revisions ├── dependencies.rs # GET /v2/revisions/:locator/dependencies + ├── teams.rs # GET /v2/teams, GET /projects/:locator/teams, PUT /teams/:id/projects └── issues.rs # GET /v2/issues/:id, GET /v2/issues, PUT /v2/issues/ ``` @@ -52,6 +53,9 @@ async fn test_workflow() { | `/projects/:locator` | GET | get_project | | `/projects/:locator` | PUT | update_project | | `/v2/projects` | GET | list_projects | +| `/projects/:locator/teams` | GET | list_project_teams | +| `/v2/teams` | GET | list_teams | +| `/teams/:id/projects` | PUT | update_team_projects (add/remove; membership lives on `Project.teams`) | | `/revisions/:locator` | GET | get_revision | | `/projects/:locator/revisions` | GET | list_revisions | | `/v2/revisions/:locator/dependencies` | GET | list_dependencies | diff --git a/src/mock_server/fixtures.rs b/src/mock_server/fixtures.rs index acdc2f2..fc94a6c 100644 --- a/src/mock_server/fixtures.rs +++ b/src/mock_server/fixtures.rs @@ -4,7 +4,7 @@ use crate::{ Dependency, Issue, IssueDepths, IssueMetric, IssueSource, IssueStatuses, LatestRevision, - Project, ProjectIssues, Revision, + Project, ProjectIssues, Revision, Team, }; /// Collection of fixture factories for test data. @@ -35,6 +35,24 @@ impl Fixtures { } } + /// Create a project that is on the given teams. + pub fn project_on_teams(locator: &str, title: &str, teams: &[&Team]) -> Project { + let mut project = Self::minimal_project(locator, title); + project.teams = teams.iter().map(|t| (*t).clone()).collect(); + project + } + + /// Create a team. + pub fn team(id: u64, name: &str) -> Team { + Team { + id, + name: name.to_string(), + unique_identifier: None, + team_projects_count: None, + team_members_count: None, + } + } + /// Create a project with issue counts. pub fn project_with_issues( locator: &str, diff --git a/src/mock_server/handlers/mod.rs b/src/mock_server/handlers/mod.rs index 237b63b..ba9fbd9 100644 --- a/src/mock_server/handlers/mod.rs +++ b/src/mock_server/handlers/mod.rs @@ -4,8 +4,10 @@ pub mod dependencies; pub mod issues; pub mod projects; pub mod revisions; +pub mod teams; pub use dependencies::*; pub use issues::*; pub use projects::*; pub use revisions::*; +pub use teams::*; diff --git a/src/mock_server/handlers/projects.rs b/src/mock_server/handlers/projects.rs index 3d59914..71a977a 100644 --- a/src/mock_server/handlers/projects.rs +++ b/src/mock_server/handlers/projects.rs @@ -20,6 +20,9 @@ pub struct ListProjectsQuery { pub page: Option, pub count: Option, pub title: Option, + /// A numeric team ID, or "null" for projects on no team. + #[serde(rename = "teamId")] + pub team_id: Option, } /// Response for listing projects. @@ -75,7 +78,23 @@ pub async fn list_projects( let page = query.page.unwrap_or(1); let count = query.count.unwrap_or(20); - let all_projects = state.list_projects(query.title.as_deref()); + let mut all_projects = state.list_projects(query.title.as_deref()); + match query.team_id.as_deref() { + None => {} + Some("null") => all_projects.retain(|p| p.teams.is_empty()), + Some(id) => { + let Ok(id) = id.parse::() else { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "message": "Invalid integer value for param 'teamId'" + })), + ) + .into_response(); + }; + all_projects.retain(|p| p.teams.iter().any(|t| t.id == id)); + } + } let total = all_projects.len() as u64; // Apply pagination @@ -95,6 +114,7 @@ pub async fn list_projects( StatusCode::OK, Json(ListProjectsResponse { projects, total }), ) + .into_response() } /// PUT /projects/{locator} diff --git a/src/mock_server/handlers/teams.rs b/src/mock_server/handlers/teams.rs new file mode 100644 index 0000000..cb89c96 --- /dev/null +++ b/src/mock_server/handlers/teams.rs @@ -0,0 +1,162 @@ +//! Team endpoint handlers. +//! +//! Membership is stored on each project's `teams` list, as the real +//! `GET /v2/projects` reports it. + +use std::sync::Arc; + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Deserialize; +use tokio::sync::RwLock; + +use crate::mock_server::state::MockState; + +/// Query parameters for listing teams. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListTeamsQuery { + pub page: Option, + pub page_size: Option, + pub search: Option, +} + +/// Body of a team membership write. +#[derive(Debug, Deserialize)] +pub struct UpdateTeamProjectsBody { + pub action: String, + pub projects: Vec, +} + +fn error(status: StatusCode, message: &str) -> Response { + (status, Json(serde_json::json!({ "message": message }))).into_response() +} + +/// Locators of every project on a team, sorted. +fn team_project_locators(state: &MockState, team_id: u64) -> Vec { + let mut locators: Vec = state + .projects + .values() + .filter(|p| p.teams.iter().any(|t| t.id == team_id)) + .map(|p| p.id.clone()) + .collect(); + locators.sort(); + locators +} + +/// GET /v2/teams +/// +/// Like the real route: `pageSize` defaults to 10 and clamps to 50, and the +/// envelope is `{results, page, pageSize, totalCount}`. +pub async fn list_teams( + State(state): State>>, + Query(query): Query, +) -> impl IntoResponse { + let state = state.read().await; + let page = query.page.unwrap_or(1).max(1); + let page_size = query.page_size.unwrap_or(10).clamp(1, 50); + + let mut teams: Vec<_> = state + .teams + .values() + .filter(|t| { + query + .search + .as_deref() + .map(|s| t.name.to_lowercase().contains(&s.to_lowercase())) + .unwrap_or(true) + }) + .cloned() + .collect(); + teams.sort_by_key(|t| t.id); + let total_count = teams.len(); + + let results: Vec<_> = teams + .into_iter() + .skip(((page - 1) * page_size) as usize) + .take(page_size as usize) + .map(|mut t| { + t.team_projects_count = Some(team_project_locators(&state, t.id).len() as u64); + t + }) + .collect(); + + Json(serde_json::json!({ + "results": results, + "page": page, + "pageSize": page_size, + "totalCount": total_count, + })) +} + +/// GET /projects/{locator}/teams +pub async fn list_project_teams( + State(state): State>>, + Path(locator): Path, +) -> impl IntoResponse { + let state = state.read().await; + let teams: Vec<_> = state + .get_project(&locator) + .map(|p| { + p.teams + .iter() + .map(|t| serde_json::json!({ "id": t.id, "name": t.name })) + .collect() + }) + .unwrap_or_default(); + Json(serde_json::json!({ "totalCount": teams.len(), "teams": teams })) +} + +/// PUT /teams/{id}/projects +/// +/// Supports `add` and `remove`. Like the real route: unknown team → 404, +/// and `add` is all-or-nothing → 403 if any locator matches no project +/// (which is what a `$revision` suffix does). +pub async fn update_team_projects( + State(state): State>>, + Path(team_id): Path, + Json(body): Json, +) -> Response { + let mut state = state.write().await; + let Some(team) = state.teams.get(&team_id).cloned() else { + return StatusCode::NOT_FOUND.into_response(); + }; + + match body.action.as_str() { + "add" => { + if !body.projects.iter().all(|l| state.projects.contains_key(l)) { + return error( + StatusCode::FORBIDDEN, + &format!( + "You are not permitted to add all selected projects to team \"{}\".", + team.name + ), + ); + } + for locator in &body.projects { + let project = state.projects.get_mut(locator).expect("checked above"); + if !project.teams.iter().any(|t| t.id == team_id) { + project.teams.push(team.clone()); + } + } + } + "remove" => { + for locator in &body.projects { + if let Some(project) = state.projects.get_mut(locator) { + project.teams.retain(|t| t.id != team_id); + } + } + } + _ => return error(StatusCode::BAD_REQUEST, "Unsupported action in mock"), + } + + Json(serde_json::json!({ + "id": team_id, + "projects": team_project_locators(&state, team_id), + })) + .into_response() +} diff --git a/src/mock_server/server.rs b/src/mock_server/server.rs index 927ee97..13606b1 100644 --- a/src/mock_server/server.rs +++ b/src/mock_server/server.rs @@ -125,6 +125,13 @@ impl MockServer { .route("/projects/:locator", get(handlers::get_project)) .route("/projects/:locator", put(handlers::update_project)) .route("/v2/projects", get(handlers::list_projects)) + .route( + "/projects/:locator/teams", + get(handlers::list_project_teams), + ) + // Team routes + .route("/v2/teams", get(handlers::list_teams)) + .route("/teams/:id/projects", put(handlers::update_team_projects)) // Revision routes .route("/revisions/:locator", get(handlers::get_revision)) .route( diff --git a/src/mock_server/state.rs b/src/mock_server/state.rs index 1bfa677..82b6a83 100644 --- a/src/mock_server/state.rs +++ b/src/mock_server/state.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; -use crate::{Dependency, Issue, Project, Revision}; +use crate::{Dependency, Issue, Project, Revision, Team}; /// Shared state for the mock server. /// @@ -27,6 +27,9 @@ pub struct MockState { /// Issues indexed by ID. pub issues: HashMap, + /// Teams indexed by ID. Membership lives on each project's `teams`. + pub teams: HashMap, + /// Optional authentication token. If set, requests must include this token. pub required_token: Option, } @@ -66,6 +69,12 @@ impl MockState { self } + /// Add a team to the state. + pub fn with_team(mut self, team: Team) -> Self { + self.teams.insert(team.id, team); + self + } + /// Set the required authentication token. pub fn with_required_token(mut self, token: &str) -> Self { self.required_token = Some(token.to_string()); diff --git a/src/models/mod.rs b/src/models/mod.rs index f0e5faa..4ec500d 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -5,9 +5,11 @@ mod issue; mod project; mod revision; mod snippet; +mod team; pub use dependency::*; pub use issue::*; pub use project::*; pub use revision::*; pub use snippet::*; +pub use team::*; diff --git a/src/models/project.rs b/src/models/project.rs index c50e362..d115ab2 100644 --- a/src/models/project.rs +++ b/src/models/project.rs @@ -61,9 +61,10 @@ pub struct Project { #[serde(default)] pub labels: Vec, - /// Teams associated with this project. + /// Teams the project is on. Filled by `GET /v2/projects`; the + /// single-project endpoint omits it (use [`crate::get_project_teams`]). #[serde(default)] - pub teams: Vec, + pub teams: Vec, /// Latest revision information. #[serde(rename = "latestRevision", default)] @@ -183,6 +184,10 @@ pub struct ProjectListQuery { /// Sort order. #[serde(skip_serializing_if = "Option::is_none")] pub sort: Option, + + /// Filter by team: a numeric team ID, or `"null"` for projects on no team. + #[serde(rename = "teamId", skip_serializing_if = "Option::is_none")] + pub team_id: Option, } /// Parameters for updating a project. diff --git a/src/models/team.rs b/src/models/team.rs new file mode 100644 index 0000000..b223cb6 --- /dev/null +++ b/src/models/team.rs @@ -0,0 +1,258 @@ +//! Team model and team membership operations. +//! +//! A project can sit on any number of teams. The API has no "move" endpoint +//! and adding a project to a team leaves its other teams untouched, so a move +//! is an add to the target followed by a remove from the source. + +use serde::{Deserialize, Serialize}; + +use crate::client::FossaClient; +use crate::error::{FossaError, Result}; +use crate::pagination::Page; + +/// Hard cap the team endpoints put on `pageSize`. +const MAX_TEAM_PAGE_SIZE: u32 = 50; + +/// A FOSSA team. +/// +/// `GET /v2/teams` fills every field; the `teams` entries on a project and +/// `GET /projects/{locator}/teams` carry only `id` and `name`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Team { + /// Numeric team ID. + pub id: u64, + + /// Team name. + pub name: String, + + /// External identifier (e.g. from SSO group sync), if set. + #[serde(default, alias = "uniqueId")] + pub unique_identifier: Option, + + /// Number of projects on the team. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_projects_count: Option, + + /// Number of members on the team. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_members_count: Option, +} + +/// A team's project membership, as returned by a membership write. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TeamProjects { + /// The team ID. + pub id: u64, + /// Every project locator on the team after the write. + pub projects: Vec, +} + +/// The result of moving projects between teams. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TeamMove { + /// The source team's membership after the remove. + pub from: TeamProjects, + /// The target team's membership after the add. + pub to: TeamProjects, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TeamListResponse { + results: Vec, + page_size: u32, + total_count: u64, +} + +#[derive(Deserialize)] +struct ProjectTeamsResponse { + teams: Vec, +} + +/// Get one page of teams, optionally filtered by a name search. +/// +/// `count` is capped at 50 by the API; the returned page reports the size +/// the API actually used. +#[tracing::instrument(skip(client))] +pub async fn get_teams_page( + client: &FossaClient, + search: Option<&str>, + page: u32, + count: u32, +) -> Result> { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Params<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + search: Option<&'a str>, + page: u32, + page_size: u32, + } + + let params = Params { + search, + page, + page_size: count.min(MAX_TEAM_PAGE_SIZE), + }; + let response = client.get_with_query("v2/teams", ¶ms).await?; + let data: TeamListResponse = response.json().await.map_err(FossaError::HttpError)?; + Ok(Page::new( + data.results, + page, + data.page_size, + Some(data.total_count), + )) +} + +/// Get the teams a project is on (only teams visible to the token). +#[tracing::instrument(skip(client))] +pub async fn get_project_teams(client: &FossaClient, project: &str) -> Result> { + let path = format!( + "projects/{}/teams", + urlencoding::encode(project_locator(project)) + ); + let response = client.get(&path).await?; + let data: ProjectTeamsResponse = response.json().await.map_err(FossaError::HttpError)?; + Ok(data.teams) +} + +/// Add projects to a team. Their other team memberships are kept. +/// +/// All-or-nothing: the API refuses the whole batch (403) if the token lacks +/// Edit on any of the projects. +pub async fn add_team_projects( + client: &FossaClient, + team_id: u64, + projects: &[String], +) -> Result { + put_team_projects(client, team_id, "add", projects).await +} + +/// Remove projects from a team. +pub async fn remove_team_projects( + client: &FossaClient, + team_id: u64, + projects: &[String], +) -> Result { + put_team_projects(client, team_id, "remove", projects).await +} + +/// Move projects from one team to another: add to `to`, then remove from +/// `from`. If the add fails nothing is removed; if the remove fails the +/// error says the projects are now on both teams. +pub async fn move_team_projects( + client: &FossaClient, + projects: &[String], + from: u64, + to: u64, +) -> Result { + if from == to { + // Add would be a no-op and the remove would drop them off the team. + return Err(FossaError::InvalidParams(format!( + "cannot move projects from team {from} to itself" + ))); + } + let to_team = + add_team_projects(client, to, projects) + .await + .map_err(|e| FossaError::ApiError { + message: format!( + "adding to team {to} failed, so nothing was removed from team {from}: {e}" + ), + status_code: None, + })?; + let from_team = remove_team_projects(client, from, projects) + .await + .map_err(|e| FossaError::ApiError { + message: format!( + "added to team {to}, but removing from team {from} failed; \ + the projects are now on both teams: {e}" + ), + status_code: None, + })?; + Ok(TeamMove { + from: from_team, + to: to_team, + }) +} + +/// `PUT /teams/{id}/projects`. Only `add` and `remove` are ever sent: the +/// API's `replace` drops every project on the team not in the list. +#[tracing::instrument(skip(client))] +async fn put_team_projects( + client: &FossaClient, + team_id: u64, + action: &str, + projects: &[String], +) -> Result { + if projects.is_empty() { + return Err(FossaError::InvalidParams( + "at least one project locator is required".to_string(), + )); + } + // A revision suffix makes the locator match no project, which 403s the + // whole add batch. + let projects: Vec<&str> = projects.iter().map(|p| project_locator(p)).collect(); + let body = serde_json::json!({ "action": action, "projects": projects }); + let response = client + .put(&format!("teams/{team_id}/projects"), &body) + .await?; + response.json().await.map_err(FossaError::HttpError) +} + +/// Strip any `$revision` suffix, leaving the project locator. +fn project_locator(locator: &str) -> &str { + locator.split('$').next().unwrap_or(locator) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_locator_strips_revision() { + assert_eq!(project_locator("custom+1/repo$abc123"), "custom+1/repo"); + assert_eq!(project_locator("custom+1/repo"), "custom+1/repo"); + } + + #[tokio::test] + async fn move_reports_both_teams_when_remove_fails() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path("/teams/2/projects")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"id": 2, "projects": ["custom+1/a"]})), + ) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/teams/1/projects")) + .respond_with(ResponseTemplate::new(403)) + .mount(&server) + .await; + + let client = FossaClient::new("t", &server.uri()).unwrap(); + let err = move_team_projects(&client, &["custom+1/a".to_string()], 1, 2) + .await + .unwrap_err(); + assert!(err.to_string().contains("now on both teams"), "{err}"); + } + + /// `GET /v2/projects` sends `teams` as objects, not strings. + #[test] + fn project_team_entry_deserializes() { + let project: crate::Project = serde_json::from_value(serde_json::json!({ + "id": "custom+1/a", + "title": "A", + "teams": [{"id": 7, "name": "Platform", "uniqueId": null, "teamType": "team"}] + })) + .unwrap(); + assert_eq!(project.teams[0].id, 7); + assert_eq!(project.teams[0].name, "Platform"); + } +} diff --git a/src/ops/list.rs b/src/ops/list.rs index 334988b..0960c2c 100644 --- a/src/ops/list.rs +++ b/src/ops/list.rs @@ -6,21 +6,49 @@ use serde::{Deserialize, Serialize}; use super::PageArgs; use crate::{ - get_dependencies_page, get_issues_page, get_revisions_page, get_snippet_locations_page, - get_snippet_paths, get_snippets_page, Dependency, FossaClient, Issue, IssueCategory, - IssueListQuery, List, Page, Project, Result, Revision, Snippet, SnippetListQuery, - SnippetLocation, SnippetPath, + get_dependencies_page, get_issues_page, get_project_teams, get_revisions_page, + get_snippet_locations_page, get_snippet_paths, get_snippets_page, get_teams_page, Dependency, + FossaClient, FossaError, Issue, IssueCategory, IssueListQuery, List, Page, Project, + ProjectListQuery, Result, Revision, Snippet, SnippetListQuery, SnippetLocation, SnippetPath, + Team, }; /// Parameters for `list projects`. #[derive(Args, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] pub struct ListProjectsParams { + /// Only projects on this team (numeric team ID). + #[arg(long)] + pub team: Option, + + /// Only projects on no team. + #[arg(long, conflicts_with = "team")] + #[serde(default)] + pub no_team: bool, + /// Pagination. #[command(flatten)] #[serde(flatten)] pub pagination: PageArgs, } +/// Parameters for `list teams`. +#[derive(Args, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +pub struct ListTeamsParams { + /// Filter by team name. + #[arg(long)] + pub search: Option, + + /// Only the teams this project is on (project locator; returns every + /// match on one page). + #[arg(long, conflicts_with = "search")] + pub project: Option, + + /// Pagination (count is capped at 50 by the API). + #[command(flatten)] + #[serde(flatten)] + pub pagination: PageArgs, +} + /// Parameters for `list issues`. #[derive(Args, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] pub struct ListIssuesParams { @@ -111,10 +139,14 @@ pub struct ListSnippetPathsParams { #[derive(Subcommand, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] #[serde(tag = "entity", rename_all = "snake_case")] pub enum ListCommand { - /// List all projects. + /// List all projects, optionally only those on one team or on none. #[command(alias = "project")] Projects(ListProjectsParams), + /// List teams, or the teams one project is on. + #[command(alias = "team")] + Teams(ListTeamsParams), + /// List all issues in a category. #[command(alias = "issue")] Issues(ListIssuesParams), @@ -144,6 +176,8 @@ pub enum ListCommand { pub enum ListOutput { /// A page of projects. Projects(Page), + /// A page of teams. + Teams(Page), /// A page of issues. Issues(Page), /// A page of dependencies. @@ -162,11 +196,38 @@ pub enum ListOutput { pub async fn run_list(client: &FossaClient, command: ListCommand) -> Result { Ok(match command { ListCommand::Projects(p) => { + if p.team.is_some() && p.no_team { + return Err(FossaError::InvalidParams( + "team and no_team are mutually exclusive".to_string(), + )); + } let (page, count) = p.pagination.resolve(); - ListOutput::Projects( - Project::list_page(client, &Default::default(), page, count).await?, - ) + let query = ProjectListQuery { + team_id: match p.team { + Some(id) => Some(id.to_string()), + None if p.no_team => Some("null".to_string()), + None => None, + }, + ..Default::default() + }; + ListOutput::Projects(Project::list_page(client, &query, page, count).await?) } + ListCommand::Teams(p) => match p.project { + Some(project) => { + if p.search.is_some() { + return Err(FossaError::InvalidParams( + "search and project are mutually exclusive".to_string(), + )); + } + let teams = get_project_teams(client, &project).await?; + let total = teams.len() as u64; + ListOutput::Teams(Page::new(teams, 1, total.max(1) as u32, Some(total))) + } + None => { + let (page, count) = p.pagination.resolve(); + ListOutput::Teams(get_teams_page(client, p.search.as_deref(), page, count).await?) + } + }, ListCommand::Issues(p) => { let (page, count) = p.pagination.resolve(); let query = IssueListQuery { diff --git a/src/ops/mod.rs b/src/ops/mod.rs index e03e473..8c6ac52 100644 --- a/src/ops/mod.rs +++ b/src/ops/mod.rs @@ -1,6 +1,7 @@ //! Shared operation definitions consumed by both the CLI and the MCP server. //! -//! Each verb (`get`, `list`, `update`, `ignore`, `unignore`) is declared once +//! Each verb (`get`, `list`, `update`, `ignore`, `unignore`, `add`, `remove`, +//! `move`) is declared once //! as an enum whose variants wrap per-entity parameter structs. The same //! declaration drives both frontends: //! @@ -20,6 +21,7 @@ mod get; mod ignore; mod list; +mod team; mod update; pub use get::{ @@ -33,7 +35,11 @@ pub use ignore::{ pub use list::{ run_list, ListCommand, ListDependenciesParams, ListIssuesParams, ListOutput, ListProjectsParams, ListRevisionsParams, ListSnippetLocationsParams, ListSnippetPathsParams, - ListSnippetsParams, + ListSnippetsParams, ListTeamsParams, +}; +pub use team::{ + run_add, run_move, run_remove, AddCommand, AddProjectParams, MoveCommand, MoveProjectParams, + RemoveCommand, RemoveProjectParams, }; pub use update::{run_update, UpdateCommand, UpdateOutput, UpdateProjectParams}; diff --git a/src/ops/team.rs b/src/ops/team.rs new file mode 100644 index 0000000..6d7fa5f --- /dev/null +++ b/src/ops/team.rs @@ -0,0 +1,108 @@ +//! The `add`, `remove`, and `move` verbs: team project membership. +//! +//! Separate verbs (not `update` flags) for the same reason as ignore/unignore: +//! they are the domain's own names, and separate MCP tools let agent +//! harnesses permission-gate removals independently of additions. + +use clap::{Args, Subcommand}; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{ + add_team_projects, move_team_projects, remove_team_projects, FossaClient, Result, TeamMove, + TeamProjects, +}; + +/// Parameters for `add project`. +#[derive(Args, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +pub struct AddProjectParams { + /// Project locators (e.g., "custom+org/repo"); any "$revision" suffix is stripped. + #[arg(required = true)] + pub locators: Vec, + + /// Numeric ID of the team to add the projects to. + #[arg(long)] + pub team: u64, +} + +/// Parameters for `remove project`. +#[derive(Args, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +pub struct RemoveProjectParams { + /// Project locators (e.g., "custom+org/repo"); any "$revision" suffix is stripped. + #[arg(required = true)] + pub locators: Vec, + + /// Numeric ID of the team to remove the projects from. + #[arg(long)] + pub team: u64, +} + +/// Parameters for `move project`. +#[derive(Args, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +pub struct MoveProjectParams { + /// Project locators (e.g., "custom+org/repo"); any "$revision" suffix is stripped. + #[arg(required = true)] + pub locators: Vec, + + /// Numeric ID of the team to take the projects off. + #[arg(long)] + pub from: u64, + + /// Numeric ID of the team to put the projects on. + #[arg(long)] + pub to: u64, +} + +/// The `add` operation, declared once for both the CLI and the MCP server. +/// +/// Adding keeps the projects' other teams. All-or-nothing: the API refuses +/// the whole batch if the token lacks Edit on any project. +#[derive(Subcommand, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(tag = "entity", rename_all = "snake_case")] +pub enum AddCommand { + /// Add projects to a team. + #[command(alias = "projects")] + Project(AddProjectParams), +} + +/// The `remove` operation, declared once for both the CLI and the MCP server. +#[derive(Subcommand, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(tag = "entity", rename_all = "snake_case")] +pub enum RemoveCommand { + /// Remove projects from a team. + #[command(alias = "projects")] + Project(RemoveProjectParams), +} + +/// The `move` operation, declared once for both the CLI and the MCP server. +/// +/// There is no move endpoint: this adds to the target team, then removes +/// from the source team. If the add fails nothing is removed. +#[derive(Subcommand, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(tag = "entity", rename_all = "snake_case")] +pub enum MoveCommand { + /// Move projects from one team to another. + #[command(alias = "projects")] + Project(MoveProjectParams), +} + +/// Execute an `add` operation; returns the team's membership after the write. +pub async fn run_add(client: &FossaClient, command: AddCommand) -> Result { + match command { + AddCommand::Project(p) => add_team_projects(client, p.team, &p.locators).await, + } +} + +/// Execute a `remove` operation; returns the team's membership after the write. +pub async fn run_remove(client: &FossaClient, command: RemoveCommand) -> Result { + match command { + RemoveCommand::Project(p) => remove_team_projects(client, p.team, &p.locators).await, + } +} + +/// Execute a `move` operation; returns both teams' membership after the writes. +pub async fn run_move(client: &FossaClient, command: MoveCommand) -> Result { + match command { + MoveCommand::Project(p) => move_team_projects(client, &p.locators, p.from, p.to).await, + } +} diff --git a/src/output.rs b/src/output.rs index c629185..97e6e31 100644 --- a/src/output.rs +++ b/src/output.rs @@ -7,6 +7,7 @@ use std::collections::BTreeSet; use crate::{ CodeLine, Issue, IssueMetric, Project, Revision, Snippet, SnippetKind, SnippetMatchDetails, + TeamMove, TeamProjects, }; /// Trait for human-readable key-value output. @@ -55,6 +56,22 @@ impl PrettyPrint for Project { } } +impl PrettyPrint for TeamProjects { + fn pretty_print(&self) -> String { + format!("Team {}: {} projects", self.id, self.projects.len()) + } +} + +impl PrettyPrint for TeamMove { + fn pretty_print(&self) -> String { + format!( + "Moved. From {}\nTo {}", + self.from.pretty_print(), + self.to.pretty_print() + ) + } +} + impl PrettyPrint for Revision { fn pretty_print(&self) -> String { let divider = "─".repeat(self.locator.len().max(30)); diff --git a/tests/cli_args.rs b/tests/cli_args.rs index f28486b..aa875c5 100644 --- a/tests/cli_args.rs +++ b/tests/cli_args.rs @@ -88,6 +88,7 @@ fn test_list_pagination_args() { command: ListCommand::Projects(ListProjectsParams { pagination: PageArgs { page, count }, + .. }), } => { assert_eq!(page, Some(2)); @@ -216,7 +217,7 @@ fn test_list_projects_parses() { let cli = Cli::parse_from(["fossapi", "list", "projects"]); match cli.command { Command::List { - command: ListCommand::Projects(ListProjectsParams { pagination }), + command: ListCommand::Projects(ListProjectsParams { pagination, .. }), } => { assert_eq!(pagination.page, None); assert_eq!(pagination.count, None); @@ -232,7 +233,7 @@ fn test_list_projects_with_pagination() { ]); match cli.command { Command::List { - command: ListCommand::Projects(ListProjectsParams { pagination }), + command: ListCommand::Projects(ListProjectsParams { pagination, .. }), } => { assert_eq!(pagination.page, Some(2)); assert_eq!(pagination.count, Some(50)); @@ -599,3 +600,39 @@ fn test_unignore_issue_has_no_notes_flag() { ]); assert!(result.is_err(), "--notes is not a flag of unignore"); } + +#[test] +fn test_move_project_parses_multiple_locators() { + let cli = Cli::parse_from([ + "fossapi", + "move", + "project", + "custom+1/a", + "custom+1/b", + "--from", + "1", + "--to", + "2", + ]); + match cli.command { + Command::Move { + command: fossapi::cli::MoveCommand::Project(p), + } => { + assert_eq!(p.locators, ["custom+1/a", "custom+1/b"]); + assert_eq!((p.from, p.to), (1, 2)); + } + _ => panic!("Expected Move command"), + } +} + +#[test] +fn test_add_project_requires_locator_and_team() { + assert!(Cli::try_parse_from(["fossapi", "add", "project", "--team", "1"]).is_err()); + assert!(Cli::try_parse_from(["fossapi", "add", "project", "custom+1/a"]).is_err()); +} + +#[test] +fn test_list_projects_team_conflicts_with_no_team() { + let result = Cli::try_parse_from(["fossapi", "list", "projects", "--team", "1", "--no-team"]); + assert!(result.is_err(), "--team and --no-team are exclusive"); +} diff --git a/tests/e2e_mock_server.rs b/tests/e2e_mock_server.rs index 8998f01..6cee1fc 100644 --- a/tests/e2e_mock_server.rs +++ b/tests/e2e_mock_server.rs @@ -6,9 +6,14 @@ #![cfg(feature = "test-server")] use fossapi::mock_server::{Fixtures, MockServer, MockState}; +use fossapi::ops::{ + run_list, run_move, ListCommand, ListOutput, ListProjectsParams, ListTeamsParams, MoveCommand, + MoveProjectParams, PageArgs, +}; use fossapi::{ - get_dependencies, FossaClient, Get, Issue, IssueAction, IssueCategory, IssueIgnoreReason, - IssueListQuery, IssueUpdateParams, List, Project, Revision, Update, + add_team_projects, get_dependencies, get_teams_page, move_team_projects, remove_team_projects, + FossaClient, Get, Issue, IssueAction, IssueCategory, IssueIgnoreReason, IssueListQuery, + IssueUpdateParams, List, Project, Revision, Update, }; /// A list query scoped to one category, which the API requires. @@ -509,3 +514,205 @@ async fn test_locator_with_special_characters() { server.shutdown().await; } + +// ============================================================================= +// Team Tests +// ============================================================================= + +/// Teams 1 ("Platform") and 2 ("Payments"); `a` and `b` on team 1, `c` on none. +async fn team_server() -> (MockServer, FossaClient) { + let platform = Fixtures::team(1, "Platform"); + let payments = Fixtures::team(2, "Payments"); + let state = MockState::new() + .with_project(Fixtures::project_on_teams("custom+1/a", "A", &[&platform])) + .with_project(Fixtures::project_on_teams("custom+1/b", "B", &[&platform])) + .with_project(Fixtures::minimal_project("custom+1/c", "C")) + .with_team(platform) + .with_team(payments); + let server = MockServer::with_state(state).await; + let client = FossaClient::new("test-token", server.url()).unwrap(); + (server, client) +} + +/// Locators of the projects on one team (or on none), via `list projects`. +async fn projects_on(client: &FossaClient, team: Option) -> Vec { + let output = run_list( + client, + ListCommand::Projects(ListProjectsParams { + team, + no_team: team.is_none(), + pagination: PageArgs::default(), + }), + ) + .await + .expect("list projects"); + let ListOutput::Projects(page) = output else { + panic!("expected projects"); + }; + let mut locators: Vec = page.items.into_iter().map(|p| p.id).collect(); + locators.sort(); + locators +} + +fn locators(ls: &[&str]) -> Vec { + ls.iter().map(|l| l.to_string()).collect() +} + +#[tokio::test] +async fn test_list_teams() { + let (server, client) = team_server().await; + + let page = get_teams_page(&client, None, 1, 20).await.unwrap(); + assert_eq!(page.total, Some(2)); + assert_eq!(page.items[0].name, "Platform"); + assert_eq!(page.items[0].team_projects_count, Some(2)); + + let page = get_teams_page(&client, Some("pay"), 1, 20).await.unwrap(); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].id, 2); + + // The API caps pageSize at 50; the page reports what was used. + let page = get_teams_page(&client, None, 1, 100).await.unwrap(); + assert_eq!(page.count, 50); + + let output = run_list( + &client, + ListCommand::Teams(ListTeamsParams { + search: None, + project: Some("custom+1/a$main".to_string()), + pagination: PageArgs::default(), + }), + ) + .await + .unwrap(); + let ListOutput::Teams(page) = output else { + panic!("expected teams"); + }; + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].name, "Platform"); + + server.shutdown().await; +} + +#[tokio::test] +async fn test_list_projects_by_team() { + let (server, client) = team_server().await; + + assert_eq!( + projects_on(&client, Some(1)).await, + ["custom+1/a", "custom+1/b"] + ); + assert!(projects_on(&client, Some(2)).await.is_empty()); + assert_eq!(projects_on(&client, None).await, ["custom+1/c"]); + + server.shutdown().await; +} + +#[tokio::test] +async fn test_add_projects_to_team() { + let (server, client) = team_server().await; + + let team = add_team_projects(&client, 2, &locators(&["custom+1/a", "custom+1/c"])) + .await + .unwrap(); + assert_eq!(team.id, 2); + assert_eq!(team.projects, ["custom+1/a", "custom+1/c"]); + // Adding keeps the other team. + assert_eq!( + projects_on(&client, Some(1)).await, + ["custom+1/a", "custom+1/b"] + ); + + // All-or-nothing: one unknown project fails the batch. + let err = add_team_projects(&client, 2, &locators(&["custom+1/b", "custom+1/nope"])) + .await + .unwrap_err(); + assert!(err.to_string().contains("not permitted"), "{err}"); + assert_eq!( + projects_on(&client, Some(2)).await, + ["custom+1/a", "custom+1/c"] + ); + + server.shutdown().await; +} + +#[tokio::test] +async fn test_remove_projects_from_team() { + let (server, client) = team_server().await; + + let team = remove_team_projects(&client, 1, &locators(&["custom+1/a"])) + .await + .unwrap(); + assert_eq!(team.projects, ["custom+1/b"]); + assert_eq!( + projects_on(&client, None).await, + ["custom+1/a", "custom+1/c"] + ); + + server.shutdown().await; +} + +#[tokio::test] +async fn test_move_projects_between_teams() { + let (server, client) = team_server().await; + + let moved = run_move( + &client, + MoveCommand::Project(MoveProjectParams { + locators: locators(&["custom+1/a"]), + from: 1, + to: 2, + }), + ) + .await + .unwrap(); + assert_eq!(moved.from.projects, ["custom+1/b"]); + assert_eq!(moved.to.projects, ["custom+1/a"]); + assert_eq!(projects_on(&client, Some(1)).await, ["custom+1/b"]); + assert_eq!(projects_on(&client, Some(2)).await, ["custom+1/a"]); + + let err = move_team_projects(&client, &locators(&["custom+1/b"]), 1, 1) + .await + .unwrap_err(); + assert!(err.to_string().contains("to itself"), "{err}"); + + server.shutdown().await; +} + +#[tokio::test] +async fn test_move_does_not_remove_when_add_fails() { + let (server, client) = team_server().await; + + // Team 99 does not exist, so the add 404s. + let err = move_team_projects(&client, &locators(&["custom+1/a"]), 1, 99) + .await + .unwrap_err(); + assert!( + err.to_string().contains("nothing was removed from team 1"), + "{err}" + ); + assert_eq!( + projects_on(&client, Some(1)).await, + ["custom+1/a", "custom+1/b"] + ); + + server.shutdown().await; +} + +#[tokio::test] +async fn test_team_writes_strip_revision_suffix() { + let (server, client) = team_server().await; + + // Sent as-is, "$main" matches no project and the add would 403. + let team = add_team_projects(&client, 2, &locators(&["custom+1/c$main"])) + .await + .unwrap(); + assert_eq!(team.projects, ["custom+1/c"]); + + let team = remove_team_projects(&client, 2, &locators(&["custom+1/c$main"])) + .await + .unwrap(); + assert!(team.projects.is_empty()); + + server.shutdown().await; +} diff --git a/tests/parity.rs b/tests/parity.rs index 78417c6..0c653a6 100644 --- a/tests/parity.rs +++ b/tests/parity.rs @@ -9,7 +9,10 @@ use clap::CommandFactory; use fossapi::cli::Cli; use fossapi::mcp::FossaServer; -use fossapi::ops::{GetCommand, IgnoreCommand, ListCommand, UnignoreCommand, UpdateCommand}; +use fossapi::ops::{ + AddCommand, GetCommand, IgnoreCommand, ListCommand, MoveCommand, RemoveCommand, + UnignoreCommand, UpdateCommand, +}; use schemars::schema_for; use std::collections::BTreeSet; @@ -36,6 +39,18 @@ fn verbs() -> Vec<(&'static str, serde_json::Value)> { "unignore", serde_json::to_value(schema_for!(UnignoreCommand)).unwrap(), ), + ( + "add", + serde_json::to_value(schema_for!(AddCommand)).unwrap(), + ), + ( + "remove", + serde_json::to_value(schema_for!(RemoveCommand)).unwrap(), + ), + ( + "move", + serde_json::to_value(schema_for!(MoveCommand)).unwrap(), + ), ] } @@ -234,4 +249,26 @@ fn tagged_payloads_round_trip() { let UnignoreCommand::Issue(p) = unignore; assert_eq!(p.id, 987654); assert_eq!(p.category, fossapi::IssueCategory::Licensing); + + let mv: MoveCommand = serde_json::from_value(serde_json::json!({ + "entity": "project", + "locators": ["custom+org/a", "custom+org/b"], + "from": 1, + "to": 2 + })) + .expect("move payload deserializes"); + let MoveCommand::Project(p) = mv; + assert_eq!(p.locators, ["custom+org/a", "custom+org/b"]); + assert_eq!((p.from, p.to), (1, 2)); + + let list: ListCommand = serde_json::from_value(serde_json::json!({ + "entity": "projects", + "no_team": true + })) + .expect("list projects payload deserializes"); + let ListCommand::Projects(p) = list else { + panic!("expected Projects variant"); + }; + assert!(p.no_team); + assert_eq!(p.team, None); } From 15b498f047595507d411e7cf37aee05849d75d72 Mon Sep 17 00:00:00 2001 From: Sara Date: Mon, 26 Oct 2026 14:35:16 -0400 Subject: [PATCH 2/4] Document team commands and endpoints Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fvv9sgYUtfaDtFUMPc5Upw --- CLAUDE.md | 5 +++++ README.md | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 688fa3d..2020708 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,10 @@ Project (top-level container) | Issues | `GET /v2/issues` | `category` **required**; `count` clamps to a minimum of 5 | | Issue | `GET /v2/issues/{id}` | Single issue with full details | | Issue actions | `PUT /v2/issues/` | Ignore/unignore. Targets in the query (`category` required, `ids[]`), action in the body (`{type, notes?, reason?}`). By ID the server is an **unguarded upsert**: the `status` query param is silently ignored, and re-ignoring overwrites notes/reason and resets the ignored-at timestamp — which is why we guard client-side (see ADR 0002). Responds `{count, issueId?}` (`issueId` only when count==1); `count: 0` = target not visible to the token. Full token only — push-only tokens can't write issues; missing resolve permission is a **400**, not 403 | +| Teams | `GET /v2/teams` | `{results, page, pageSize, totalCount}`; `pageSize` defaults 10, capped at 50; `search` matches name | +| Project teams | `GET /projects/{locator}/teams` | `{totalCount, teams: [{id, name}]}`, not paginated. `GET /projects/{locator}` has no teams; `GET /v2/projects` does (as objects) | +| Projects by team | `GET /v2/projects?teamId=N` | `teamId=null` = projects on no team | +| Team membership | `PUT /teams/{id}/projects` | Body `{action, projects: [locator]}` → `{id, projects}`. We send only `add`/`remove`: `replace` drops every project not listed. `add` keeps other teams, is all-or-nothing (403 if the token lacks Edit on any project; a `$revision` suffix matches nothing, so we strip it). No move endpoint: move = add then remove. `PUT /projects/{locator}` silently drops a `teams` field | | Snippets | `GET /revisions/{locator}/snippets` | Paginated; `pageSize` capped at 50 (`list_all` overrides) | | Snippet paths | `GET /revisions/{locator}/snippets/paths` | File/dir tree, drill in via `path` | | Snippet details | `GET /revisions/{locator}/snippets/{id}` | Single snippet + its per-file matches | @@ -164,6 +168,7 @@ advertise them is issue #41. - **Dependency** - Package dependency, implements List only (via revision) - **Issue** - Vulnerability/licensing/quality issue, implements Get/List/Update. Update = ignore/unignore via `IssueUpdateParams` (`IssueAction::Ignore { notes, reason }` / `Unignore`), surfaced as the top-level `ignore`/`unignore` verbs on both surfaces; it pre-fetches the issue and refuses actions whose target state already fully holds ("unignore it first" — see ADR 0002; partial org-wide states accept both actions, like the UI's global view), then re-fetches and returns the refreshed issue. `reason` is refused outside the vulnerability category (the server stores it for any category, but nothing ever displays it for licensing/quality — write-only noise). `IssueIgnoreReason` is an enum because the API resolves the reason string against its `ResolutionReasons` table and silently stores NULL on a mismatch. `Issue` has no `deny_unknown_fields`, so any API key not declared on the struct is silently dropped — when the API grows a field, add it here or callers never see it. `cpes` is deliberately unmodeled (empty on all 80 sampled issues); `patchedVersionRanges` is modeled but rarely populated (1/80) — prefer `remediation` for upgrade targets. `IssueProject` entries carry the revision the issue was found in (`revision_id`, `latest`, `first_found_at`), not just the project. - **Snippet** - Third-party (OSS) code matched into first-party files, implements List only (via revision). Read-only; reached through the `get_snippet_*` convenience functions. Quirks: `id` is a string, `matchDetails.matchPercentage` is 0-100 (other percentages are 0-1), and whole-file matches highlight a trailing blank EOF line that is excluded from the reported range. +- **Team** - Read via `get_teams_page`/`get_project_teams`; membership written via `add_team_projects`/`remove_team_projects`/`move_team_projects` (the `add`/`remove`/`move` verbs). `Project.teams` holds `Team`s (id + name) - **LicenseInfo** - Can be simple string ("MIT") or full object ## Issue Categories diff --git a/README.md b/README.md index 36b957f..ddb04ca 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,35 @@ fossapi get project "custom+1/my-project" fossapi update project "custom+1/my-project" --title "New Title" ``` +### Teams + +```bash +# List teams (optionally search by name) +fossapi list teams --search platform + +# Which teams is a project on? +fossapi list teams --project "custom+1/my-project" + +# Projects on one team, or on no team +fossapi list projects --team 12 +fossapi list projects --no-team + +# Add or remove projects (several locators at once) +fossapi add project "custom+1/a" "custom+1/b" --team 12 +fossapi remove project "custom+1/a" --team 12 + +# Move projects between teams +fossapi move project "custom+1/a" --from 12 --to 34 +``` + +Team IDs are numeric. A project can be on several teams, and `add` keeps its +other teams, so `move` is an add to `--to` followed by a remove from +`--from`: if the add fails nothing is removed; if the remove fails the error +says the projects are now on both teams. `add` is all-or-nothing: FOSSA +refuses the whole batch if the token lacks Edit on any project. Any +`$revision` suffix on a locator is stripped. `list teams` pages at most 50 +per page. + ### Revisions ```bash @@ -182,10 +211,13 @@ input schemas are generated from the same declarations the CLI parses into). | Tool | Entities | |------|----------| | `get` | `project`, `revision`, `issue` (category optional — omitted probes all three), `snippet`, `snippet_match` | -| `list` | `projects`, `issues` (category required), `dependencies`, `revisions`, `snippets`, `snippet_locations`, `snippet_paths` | +| `list` | `projects` (optional `team` or `no_team`), `teams` (optional `search` or `project`), `issues` (category required), `dependencies`, `revisions`, `snippets`, `snippet_locations`, `snippet_paths` | | `update` | `project` (title, description, url, public, policy_id, default_branch) | | `ignore` | `issue` (category required; optional notes, and reason on vulnerabilities) | | `unignore` | `issue` (category required) | +| `add` | `project` (`locators`, `team`) | +| `remove` | `project` (`locators`, `team`) | +| `move` | `project` (`locators`, `from`, `to`) | For example, `fossapi get issue 12345 --category licensing` is `get {"entity": "issue", "id": 12345, "category": "licensing"}` over MCP, and From 508a738dec97f7a295f0687f21fc0b9b2c648aea Mon Sep 17 00:00:00 2001 From: Sara Date: Mon, 26 Oct 2026 14:46:29 -0400 Subject: [PATCH 3/4] Update async-trait to fix double_must_use clippy errors on stable Clippy on current stable rejects the #[must_use] that async-trait 0.1.89 emits on trait methods. 0.1.92 no longer emits it. This also unbreaks main. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fvv9sgYUtfaDtFUMPc5Upw --- Cargo.lock | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fbffb9e..4306c51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,13 +115,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.9", ] [[package]] @@ -1799,6 +1799,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" From 431d4f895119ae834adf298156418865f025e655 Mon Sep 17 00:00:00 2001 From: Sara Date: Mon, 26 Oct 2026 15:49:05 -0400 Subject: [PATCH 4/4] Reject an empty locator list in move before the add step An empty list reached the add, whose InvalidParams error was rewrapped as ApiError, so MCP returned internal_error instead of invalid_params. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fvv9sgYUtfaDtFUMPc5Upw --- src/models/team.rs | 6 ++++++ tests/e2e_mock_server.rs | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/models/team.rs b/src/models/team.rs index b223cb6..3a48dd7 100644 --- a/src/models/team.rs +++ b/src/models/team.rs @@ -153,6 +153,12 @@ pub async fn move_team_projects( "cannot move projects from team {from} to itself" ))); } + if projects.is_empty() { + // Checked here so the add-error wrapping below can't relabel it. + return Err(FossaError::InvalidParams( + "at least one project locator is required".to_string(), + )); + } let to_team = add_team_projects(client, to, projects) .await diff --git a/tests/e2e_mock_server.rs b/tests/e2e_mock_server.rs index 6cee1fc..50a91c8 100644 --- a/tests/e2e_mock_server.rs +++ b/tests/e2e_mock_server.rs @@ -12,8 +12,8 @@ use fossapi::ops::{ }; use fossapi::{ add_team_projects, get_dependencies, get_teams_page, move_team_projects, remove_team_projects, - FossaClient, Get, Issue, IssueAction, IssueCategory, IssueIgnoreReason, IssueListQuery, - IssueUpdateParams, List, Project, Revision, Update, + FossaClient, FossaError, Get, Issue, IssueAction, IssueCategory, IssueIgnoreReason, + IssueListQuery, IssueUpdateParams, List, Project, Revision, Update, }; /// A list query scoped to one category, which the API requires. @@ -676,6 +676,9 @@ async fn test_move_projects_between_teams() { .unwrap_err(); assert!(err.to_string().contains("to itself"), "{err}"); + let err = move_team_projects(&client, &[], 1, 2).await.unwrap_err(); + assert!(matches!(err, FossaError::InvalidParams(_)), "{err}"); + server.shutdown().await; }