Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Project (top-level container)
| Project | `GET /projects/{locator}` | Single project |
| Revisions | `GET /projects/{locator}/revisions` | Grouped by branch |
| Dependencies | `GET /v2/revisions/{locator}/dependencies` | For a revision |
| Issues | `GET /v2/issues` | Paginated, filterable by category/project |
| Issues | `GET /v2/issues` | `category` **required**; `count` clamps to a minimum of 5 |
| Issue | `GET /v2/issues/{id}` | Single issue with full details |
| 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` |
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ fossapi list dependencies "custom+1/my-project\$abc123"
### Issues

Issues come in three categories: `vulnerability`, `licensing`, and `quality`.
The API scopes every issue lookup to one category, so `--category` is required
when listing.

```bash
# List vulnerabilities
Expand All @@ -73,8 +75,11 @@ fossapi list issues --category vulnerability
# List licensing issues
fossapi list issues --category licensing

# Get a specific issue
# Get a specific issue; searches each category in turn
fossapi get issue 12345

# Skip the search when you know the category
fossapi get issue 12345 --category licensing
```

### Snippets
Expand Down
21 changes: 16 additions & 5 deletions src/bin/fossapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use clap::Parser;
use fossapi::cli::{Cli, Command, Entity, GetCommand, ListCommand};
use fossapi::{
get_dependencies, FossaClient, Get, Issue, List, Page, PrettyPrint, Project,
get_dependencies, FossaClient, Get, Issue, IssueListQuery, List, Page, PrettyPrint, Project,
ProjectUpdateParams, Revision, Snippet, SnippetListQuery, SnippetLocation, SnippetPath, Update,
};
use serde::Serialize;
Expand Down Expand Up @@ -63,8 +63,11 @@ async fn handle_get(
let revision = Revision::get(client, locator).await?;
output_single(&revision, json)?;
}
GetCommand::Issue { id } => {
let issue = Issue::get(client, id).await?;
GetCommand::Issue { id, category } => {
let issue = match category {
Some(category) => Issue::get_with_category(client, id, category).await?,
None => Issue::get(client, id).await?,
};
output_single(&issue, json)?;
}
GetCommand::Snippet { revision, snippet } => {
Expand Down Expand Up @@ -95,10 +98,18 @@ async fn handle_list(
let projects = Project::list_page(client, &Default::default(), page, count).await?;
output_page(&projects, json, |p| ProjectRow::from(p))?;
}
ListCommand::Issues { page, count } => {
ListCommand::Issues {
page,
count,
category,
} => {
let page = page.unwrap_or(1);
let count = count.unwrap_or(20);
let issues = Issue::list_page(client, &Default::default(), page, count).await?;
let query = IssueListQuery {
category: Some(category),
..Default::default()
};
let issues = Issue::list_page(client, &query, page, count).await?;
output_page(&issues, json, |i| IssueRow::from(i))?;
}
ListCommand::Dependencies { revision, revision_positional } => {
Expand Down
10 changes: 10 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use clap::{Parser, Subcommand, ValueEnum};

use crate::IssueCategory;

/// FOSSA API command-line interface.
#[derive(Parser, Debug)]
#[command(name = "fossapi", about = "FOSSA API CLI", version)]
Expand Down Expand Up @@ -80,6 +82,10 @@ pub enum GetCommand {
Issue {
/// The issue ID.
id: u64,

/// Issue category. Omit to search every category (up to 3 requests).
#[arg(long, value_enum)]
category: Option<IssueCategory>,
},
#[command(
about = "Get a snippet's details, including its matched first-party files",
Expand Down Expand Up @@ -126,6 +132,10 @@ pub enum ListCommand {
/// Number of items per page.
#[arg(long)]
count: Option<u32>,

/// Issue category to list.
#[arg(long, value_enum)]
category: IssueCategory,
},
/// List dependencies for a revision.
#[command(alias = "dependency")]
Expand Down
3 changes: 2 additions & 1 deletion src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use std::sync::Arc;

use crate::{
mcp::{EntityType, GetParams, ListParams, SnippetMatchParams, UpdateParams},
DependencyListQuery, FossaClient, FossaError, Get, Issue, IssueCategory, IssueListQuery, List,
DependencyListQuery, FossaClient, FossaError, Get, Issue, IssueListQuery, List,
Project, ProjectListQuery, ProjectUpdateParams, Revision, RevisionListQuery, SnippetListQuery,
Update,
};
Expand Down Expand Up @@ -411,6 +411,7 @@ impl ServerHandler for FossaServer {
#[cfg(test)]
mod tests {
use super::*;
use crate::IssueCategory;
use wiremock::matchers::{method, path, path_regex, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

Expand Down
41 changes: 24 additions & 17 deletions src/mock_server/handlers/issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,11 @@ pub async fn get_issue(
Path(id): Path<String>,
Query(query): Query<GetIssueQuery>,
) -> impl IntoResponse {
// Validate category is provided (required by FOSSA API)
if query.category.is_none() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Invalid option: expected one of \"licensing\"|\"vulnerability\"|\"quality\" at \"category\""
})),
)
.into_response();
}
let Some(category) = query.category.as_deref() else {
return missing_category();
};

// Parse the ID as u64
let id: u64 = match id.parse() {
let id = match id.parse::<u64>() {
Ok(id) => id,
Err(_) => {
return (
Expand All @@ -74,30 +65,46 @@ pub async fn get_issue(

let state = state.read().await;

match state.get_issue(id) {
match state.get_issue_in_category(id, category) {
Some(issue) => (StatusCode::OK, Json(issue.clone())).into_response(),
None => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Issue not found",
"message": format!("No issue found with ID: {}", id)
"message": format!("No {} issue found with ID: {}", category, id)
})),
)
.into_response(),
}
}

/// The 400 the FOSSA API returns when the required `category` param is absent.
fn missing_category() -> axum::response::Response {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Invalid option: expected one of \"licensing\"|\"vulnerability\"|\"quality\" at \"category\""
})),
)
.into_response()
}

/// GET /v2/issues
pub async fn list_issues(
State(state): State<Arc<RwLock<MockState>>>,
Query(query): Query<ListIssuesQuery>,
) -> impl IntoResponse {
let Some(category) = query.category.as_deref() else {
return missing_category();
};

let state = state.read().await;

let page = query.page.unwrap_or(1);
let count = query.count.unwrap_or(20);

let all_issues = state.list_issues(query.category.as_deref());
let all_issues = state.list_issues(Some(category));

// Apply pagination
let start = ((page - 1) * count) as usize;
Expand All @@ -109,5 +116,5 @@ pub async fn list_issues(
vec![]
};

(StatusCode::OK, Json(ListIssuesResponse { issues }))
(StatusCode::OK, Json(ListIssuesResponse { issues })).into_response()
}
9 changes: 9 additions & 0 deletions src/mock_server/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ impl MockState {
.collect()
}

/// Get an issue by ID, but only if it belongs to `category`.
///
/// The real API answers 404 when the ID exists under a different category,
/// which is what drives the category search in `Issue::get`.
pub fn get_issue_in_category(&self, id: u64, category: &str) -> Option<&Issue> {
self.get_issue(id)
.filter(|i| i.issue_type.eq_ignore_ascii_case(category))
}

/// List all issues, optionally filtered by category.
pub fn list_issues(&self, category: Option<&str>) -> Vec<&Issue> {
self.issues
Expand Down
85 changes: 80 additions & 5 deletions src/models/issue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use clap::ValueEnum;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

Expand Down Expand Up @@ -80,6 +81,51 @@ mod tests {
assert_eq!(issue.exploitability.as_deref(), Some("MATURE"));
assert!(issue.epss.is_some());
assert_eq!(issue.cwes, vec!["CWE-254"]);

let remediation = issue
.remediation
.expect("Vulnerability should have remediation");
assert_eq!(remediation.partial_fix.as_deref(), Some("1.15.4"));
assert_eq!(remediation.complete_fix.as_deref(), Some("1.16.0"));
assert_eq!(remediation.partial_fix_distance.as_deref(), Some("PATCH"));
assert_eq!(remediation.complete_fix_distance.as_deref(), Some("MAJOR"));
}

/// Guards the camelCase mapping: every key here is one the API actually
/// sends, and each is `Option` + `#[serde(default)]`, so a rename would
/// silently deserialize to `None` rather than fail.
#[test]
fn test_issue_remediation_deserialize_all_fields() {
let json = r#"{
"partialFix": "5.0.52",
"completeFix": "6.0.0",
"partialFixDistance": "MINOR",
"completeFixDistance": "MAJOR"
}"#;

let remediation =
serde_json::from_str::<IssueRemediation>(json).expect("Failed to deserialize");

assert_eq!(remediation.partial_fix.as_deref(), Some("5.0.52"));
assert_eq!(remediation.complete_fix.as_deref(), Some("6.0.0"));
assert_eq!(remediation.partial_fix_distance.as_deref(), Some("MINOR"));
assert_eq!(remediation.complete_fix_distance.as_deref(), Some("MAJOR"));
}

#[test]
fn test_issue_without_remediation_deserializes() {
let json = r#"{
"id": 28,
"type": "vulnerability",
"source": {"id": "npm+lodash$4.2.0"},
"depths": {"direct": 1, "deep": 0},
"statuses": {"active": 1, "ignored": 0},
"projects": []
}"#;

let issue = serde_json::from_str::<Issue>(json).expect("Failed to deserialize");

assert!(issue.remediation.is_none());
}

#[test]
Expand Down Expand Up @@ -669,7 +715,7 @@ pub struct IssueEpss {
}

/// Issue category for filtering.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum IssueCategory {
/// Security vulnerabilities.
Expand All @@ -680,6 +726,15 @@ pub enum IssueCategory {
Quality,
}

impl IssueCategory {
/// Every category, in the order [`Issue::get`] probes them.
pub const ALL: [IssueCategory; 3] = [
IssueCategory::Vulnerability,
IssueCategory::Licensing,
IssueCategory::Quality,
];
}

/// Query parameters for listing issues.
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -719,12 +774,32 @@ struct IssueListResponse {
impl Get for Issue {
type Id = u64;

/// Fetch an issue by ID, discovering its category.
///
/// The API requires a category and answers `404` when the ID exists under a
/// different one, so this probes [`IssueCategory::ALL`] in order and returns
/// the first hit — up to three requests. Prefer
/// [`Issue::get_with_category`] when the category is already known.
///
/// Only `404` advances to the next category; any other failure (auth, rate
/// limit, server error) is returned as-is rather than being reported as a
/// missing issue.
#[tracing::instrument(skip(client))]
async fn get(client: &FossaClient, id: Self::Id) -> Result<Self> {
let path = format!("v2/issues/{id}");
let response = client.get(&path).await?;
let issue: Issue = response.json().await.map_err(FossaError::HttpError)?;
Ok(issue)
for category in IssueCategory::ALL {
match Issue::get_with_category(client, id, category).await {
Err(FossaError::ApiError {
status_code: Some(404),
..
}) => continue,
result => return result,
}
}

Err(FossaError::NotFound {
entity_type: "Issue",
id: id.to_string(),
})
}
}

Expand Down
Loading
Loading