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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@ so the two surfaces expose identical operations with identical parameters.
- `page`/`count` values below 1 are clamped to 1 (`count` is capped at 100).
- MCP calls using the legacy arg shapes fail with a migration hint naming
this change.
- **Issues can be ignored and unignored** — the first issue write surface,
as new top-level verbs on both surfaces. CLI: `ignore issue <id>
--category <c> [--notes <comment>] [--reason <r>]` and `unignore issue
<id> --category <c>`; MCP: new `ignore` and `unignore` tools. Ignoring an
already-ignored issue is refused with a prompt to unignore first (see ADR
0002), and `--reason` is accepted on vulnerability issues only (FOSSA
never displays reasons elsewhere). Requires a full API token (push-only
tokens cannot write issues).
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Project (top-level container)
| Dependencies | `GET /v2/revisions/{locator}/dependencies` | For a revision |
| 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 |
| 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 |
Expand Down Expand Up @@ -161,7 +162,7 @@ advertise them is issue #41.
- **Project** - Top-level container, implements Get/List/Update
- **Revision** - Snapshot at point in time, implements Get/List
- **Dependency** - Package dependency, implements List only (via revision)
- **Issue** - Vulnerability/licensing/quality issue, implements Get/List. `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.
- **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.
- **LicenseInfo** - Can be simple string ("MIT") or full object

Expand All @@ -182,6 +183,9 @@ All three categories also carry `url` (deep link into the FOSSA UI).
- **IssueScan** - Issue scans tied to revisions (not yet implemented)
- **Snippet reject/unreject** - Mutating a snippet's rejection status (out of scope for v1)
- **Cross-revision snippet compare** - Diffing snippet matches across revisions (out of scope for v1)
- **Issue exceptions** - `PUT /v2/issues/` also accepts `type: issueException` (org/policy-wide ignores, expirations, package labels; premium-gated) and there are `PUT`/`DELETE /v2/issues/exceptions` endpoints — the single-issue ignore/unignore we model is the common case; exceptions are unmodeled
- **Bulk issue actions** - the API natively batches (`ids[]` array, or filter-wide when `ids` is omitted — the filter-wide form mass-ignores everything matching, so exposing it needs care); we deliberately send one ID per call and guard each with a pre-flight fetch
- **Editing an ignore's notes** - deliberately requires unignore-then-re-ignore (matching the UI); the server's raw re-ignore overwrite is reachable only by hand-rolled API calls

## Nudge

Expand Down
59 changes: 55 additions & 4 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ and exposed identically by both surfaces.
_Avoid_: endpoint, command

**Verb**:
One of `get`, `list`, `update` — the top-level grouping of operations. Each
verb is one shared enum and one MCP tool. (In code the enums are spelled
`GetCommand`/`ListCommand`/`UpdateCommand`; "command" in a type name means
verb, not operation.)
One of `get`, `list`, `update`, `ignore`, `unignore` — the top-level grouping
of operations. Each verb is one shared enum and one MCP tool. (In code the
enums are spelled `GetCommand`/`ListCommand`/etc.; "command" in a type name
means verb, not operation.)
_Avoid_: action, method

**Entity**:
Expand All @@ -40,3 +40,54 @@ same parameters.
The defaults and global bounds applied to `page`/`count` before an operation
runs. One policy for all operations; individual FOSSA endpoints may impose
their own tighter bounds.

### Issues

**Issue**:
A detected problem in a dependency — a vulnerability, licensing conflict, or
quality concern. Always scoped to exactly one Category and identified by a
numeric ID, not a locator.
_Avoid_: alert, finding

**Category**:
Which of the three issue kinds an issue belongs to: vulnerability, licensing,
or quality. Every issue read and write requires one.
_Avoid_: type, kind

**Ignore**:
The only issue status transition: an active issue becomes ignored, optionally
carrying Notes and a Reason. There is no "resolved" status — when people say
"resolve an issue" they mean ignore it.
_Avoid_: resolve, suppress, dismiss, mute

**Unignore**:
Reverting an Ignore, returning the issue to active.
_Avoid_: reopen, reactivate

**Notes**:
Free text attached to an Ignore explaining it (e.g. "false positive patch").
Not a Comment.
_Avoid_: comment, message

**Reason**:
One of a closed set of structured explanations attached to a vulnerability
Ignore (Fixed, Vulnerable code not in execute path, Other, …). Vulnerability
issues only — nothing in FOSSA ever displays a reason for licensing or
quality ignores.
_Avoid_: justification, cause

**Issue exception**:
An org- or policy-wide ignore that can expire, distinct from ignoring one
issue. Exists in FOSSA but is not modeled in fossapi.

### Adjacent FOSSA concepts (not issue ignores)

**Comment**:
A separate FOSSA feature: discussion threads attached to a package, org-wide
across versions. Unrelated to an Ignore's Notes. Not modeled in fossapi.

**Package ignore**:
A separate FOSSA feature: hiding a dependency from the inventory entirely
("Ignore package" in the UI). Not an issue status change. Not modeled in
fossapi.
_Avoid_: conflating with Ignore
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,29 @@ fossapi get issue 12345

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

# Ignore an issue with a comment
fossapi ignore issue 12345 --category licensing --notes "false positive patch"

# Revert the ignore
fossapi unignore issue 12345 --category licensing
```

Ignoring supports an optional `--notes` free-text comment and, for
**vulnerability issues only**, a structured `--reason` (one of `fixed`,
`under-investigation`, `incorrect-data-found`, `component-not-present`,
`vulnerable-code-not-present`, `vulnerable-code-not-in-execute-path`,
`vulnerable-code-cannot-be-controlled-by-adversary`,
`inline-mitigations-already-exist`, `other`) — FOSSA never displays reasons
for licensing or quality ignores, so fossapi rejects them there. Issue writes
require a full API token; push-only tokens can only read.

Ignoring an issue that is already fully ignored fails with a prompt to
unignore it first — changing an existing ignore's notes is a deliberate
two-step (`unignore`, then `ignore --notes ...`), matching the web UI. An
issue ignored in some projects but active in others accepts both actions,
which then apply org-wide.

### Snippets

Snippet scanning finds third-party (open-source) code copied into your
Expand Down Expand Up @@ -163,9 +184,13 @@ input schemas are generated from the same declarations the CLI parses into).
| `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` |
| `update` | `project` (title, description, url, public, policy_id, default_branch) |
| `ignore` | `issue` (category required; optional notes, and reason on vulnerabilities) |
| `unignore` | `issue` (category required) |

For example, `fossapi get issue 12345 --category licensing` is
`get {"entity": "issue", "id": 12345, "category": "licensing"}` over MCP.
`get {"entity": "issue", "id": 12345, "category": "licensing"}` over MCP, and
`fossapi ignore issue 12345 --category licensing --notes "false positive patch"`
is `ignore {"entity": "issue", "id": 12345, "category": "licensing", "notes": "false positive patch"}`.

> **Snippets over MCP:** use `list` with `entity: snippet_locations` and
> `revision: <revision locator>` (optional `path` and `with_lines`) to map
Expand Down
31 changes: 31 additions & 0 deletions docs/adr/0002-client-side-issue-write-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Issue writes add a client-side state guard the server doesn't have

FOSSA's `PUT /v2/issues/` is an unguarded upsert when targeting by ID: it
silently ignores the `status` query filter, and re-ignoring an already-ignored
issue overwrites its notes/reason and resets its ignored-at timestamp (the
server's `ON CONFLICT DO UPDATE` on `IssueResolutions`). We decided fossapi's
`ignore issue` pre-fetches the issue and **refuses** an action whose target
state already fully holds ("already ignored; unignore it first"), mirroring
the web UI, which only ever offers Ignore on active issues and Unignore on
ignored ones. Partially ignored issues (org-wide rollup: ignored in some
projects, active in others) accept both actions, like the UI's global issue
view.

## Considered Options

Mirroring the server (letting re-ignore silently overwrite) would have given
one-step "edit the notes" — the raw API is in fact the only way to do that in
one step — but an agent retrying or mis-aiming an ignore would clobber a
human's hand-written justification without any signal. We chose the guard and
made editing notes a deliberate two-step: unignore, then re-ignore with the
new notes.

## Consequences

- Every issue write costs a pre-flight GET (and a post-write refresh GET);
there is a benign race window between fetch and write.
- `count: 0` from the server, after the pre-flight has ruled out a wrong ID or
category, means only "not visible to this token".
- The same UI-mirroring stance also rejects a `reason` on non-vulnerability
ignores: the server stores one for any category, but only vulnerability
ignores ever display it (UI, SBOM/VEX) — elsewhere it is write-only.
10 changes: 9 additions & 1 deletion src/bin/fossapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

use clap::Parser;
use fossapi::cli::{Cli, Command};
use fossapi::ops::{run_get, run_list, run_update, ListOutput};
use fossapi::ops::{run_get, run_ignore, run_list, run_unignore, run_update, ListOutput};
use fossapi::{FossaClient, Page, PrettyPrint, Project, Snippet, SnippetLocation, SnippetPath};
use serde::Serialize;
use std::process::ExitCode;
Expand Down Expand Up @@ -49,6 +49,14 @@ async fn run(client: &FossaClient, cli: Cli) -> fossapi::Result<()> {
let output = run_update(client, command).await?;
output_single(&output, cli.json)
}
Command::Ignore { command } => {
let output = run_ignore(client, command).await?;
output_single(&output, cli.json)
}
Command::Unignore { command } => {
let output = run_unignore(client, command).await?;
output_single(&output, cli.json)
}
Command::Mcp { verbose } => handle_mcp(client, verbose).await,
}
}
Expand Down
14 changes: 13 additions & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

use clap::{Parser, Subcommand};

pub use crate::ops::{GetCommand, ListCommand, UpdateCommand};
pub use crate::ops::{GetCommand, IgnoreCommand, ListCommand, UnignoreCommand, UpdateCommand};

/// FOSSA API command-line interface.
#[derive(Parser, Debug)]
Expand Down Expand Up @@ -42,6 +42,18 @@ pub enum Command {
command: UpdateCommand,
},

/// Ignore an issue (with an optional comment and reason).
Ignore {
#[command(subcommand)]
command: IgnoreCommand,
},

/// Revert a previous ignore, returning the issue to active.
Unignore {
#[command(subcommand)]
command: UnignoreCommand,
},

/// Run the MCP server on stdio.
Mcp {
/// Enable verbose (debug) logging.
Expand Down
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,12 @@ pub use models::{
DependencyQuery,
// Issue types
Issue,
IssueAction,
IssueActionResponse,
IssueCategory,
IssueDepths,
IssueEpss,
IssueIgnoreReason,
IssueListQuery,
IssueMetric,
IssueProject,
Expand All @@ -96,6 +99,7 @@ pub use models::{
IssueStatus,
IssueStatuses,
IssueType,
IssueUpdateParams,
// Project types
LatestRevision,
LicenseInfo,
Expand Down
Loading
Loading