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/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" 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 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..3a48dd7 --- /dev/null +++ b/src/models/team.rs @@ -0,0 +1,264 @@ +//! 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" + ))); + } + 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 + .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..50a91c8 100644 --- a/tests/e2e_mock_server.rs +++ b/tests/e2e_mock_server.rs @@ -6,8 +6,13 @@ #![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, + add_team_projects, get_dependencies, get_teams_page, move_team_projects, remove_team_projects, + FossaClient, FossaError, Get, Issue, IssueAction, IssueCategory, IssueIgnoreReason, IssueListQuery, IssueUpdateParams, List, Project, Revision, Update, }; @@ -509,3 +514,208 @@ 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}"); + + let err = move_team_projects(&client, &[], 1, 2).await.unwrap_err(); + assert!(matches!(err, FossaError::InvalidParams(_)), "{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); }