Skip to content

Commit d8cd74e

Browse files
committed
feat(scan): add --apply + structured updates for auto-update bot workflows
Enables `socket-patch scan` as the engine for an automated "update all patches" workflow — a cron job or PR check that runs scan, detects new or updated patches against the local manifest, applies them, and either commits the change or opens a PR. Today this isn't quite possible because: * `scan --json` is read-only — it prints the discovery JSON and exits before the apply path runs, so there's no clean way to make it mutate the manifest from a bot. * Updates aren't reported in JSON — update detection (existing manifest entry with same PURL but different UUID) only runs in the non-JSON table-print path, so a `--json` consumer can't tell which patches would be updates vs net-new additions. * Per-patch JSON records lose the added-vs-updated distinction — every successful download is reported as `action: "added"` even when it's replacing an existing entry with a newer UUID. Three additive (semver-MINOR) changes resolve all of the above: 1. `commands/get.rs` — `download_and_apply_patches` now emits per-patch `{action: "updated", oldUuid}` when the PURL already had a different UUID before insert. A new pure helper `decide_patch_action(manifest, purl, new_uuid)` returns `Added | Updated{old_uuid} | Skipped` and is unit-tested independently. 2. `commands/scan.rs` — new `--apply` flag (default `false`) opts JSON callers into the full discover → select → apply pipeline. Without `--apply`, `scan --json` keeps its prior read-only contract; with it, `scan --json --apply` runs the same selection + download path the non-JSON branch uses and emits one combined JSON object with an `apply` sub-object reporting per-patch outcomes. The JSON discovery emission also now always includes a top-level `updates` array (with `purl`, `oldUuid`, `newUuid`) computed via a new pure helper `detect_updates`. `severity_order` is exposed as `pub(crate)` so it can be unit-tested. 3. `CLI_CONTRACT.md` documents the new `--apply` flag, the full `scan` discovery and `--apply` JSON shapes, and pins the per-patch action vocabulary (`added`/`updated`/`skipped`/`failed`) with semver policy clauses for adding (MINOR) or renaming/removing (MAJOR) values. ## Tests * scan.rs inline #[cfg(test)] mod tests — 4 severity_order cases + 8 detect_updates cases covering: no manifest, empty packages, no overlap, same UUID, different UUID, multiple updates, empty patch list, first-patch candidate selection. * get.rs inline test module — 4 decide_patch_action cases covering Added (no existing entry), Skipped (same UUID), Updated (different UUID with oldUuid populated), and Added-for-different-PURL (keying on PURL not UUID). * tests/cli_parse_scan.rs — `--apply` parser tests (defaults false, long form, combines with --json/--yes) + a subprocess JSON-shape test that runs the compiled binary against an empty tempdir and asserts the new `updates: []` key is present in stdout. All 416 lib tests pass, all integration tests pass, clippy clean. ## How a bot uses this ```bash socket-patch scan --json --apply --yes > scan-result.json jq '.apply.patches[] | select(.action == "updated") | {purl, oldUuid, uuid}' scan-result.json # Pipe into peter-evans/create-pull-request with a PR body summarizing the diff. ``` Exit code: 0 on full success (every selected patch added/updated/skipped), 1 if any `failed` records are present (and top-level `status` becomes `"partial_failure"`). Assisted-by: Claude Code:claude-opus-4-7
1 parent b96a13f commit d8cd74e

4 files changed

Lines changed: 540 additions & 20 deletions

File tree

crates/socket-patch-cli/CLI_CONTRACT.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ The hidden alias `--no-apply` on `--save-only` is **part of the contract** — i
8383
| `--api-token` || (none) | string |
8484
| `--ecosystems` || (none) | CSV → `Vec<String>` |
8585
| `--download-mode` || **`diff`** | string |
86+
| `--apply` || `false` | bool |
87+
88+
`--apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + `updates` array only). No effect outside `--json` mode — the non-JSON path always prompts the user interactively. Designed for unattended workflows (cron jobs, bots that open PRs).
8689

8790
### `list`
8891

@@ -212,6 +215,71 @@ When `--json` is set, commands print a single JSON object to stdout. The schemas
212215
}
213216
```
214217

218+
### `scan` — discovery (read-only, default `--json` mode)
219+
220+
```json
221+
{
222+
"status": "success",
223+
"scannedPackages": 42,
224+
"packagesWithPatches": 3,
225+
"totalPatches": 5,
226+
"freePatches": 4,
227+
"paidPatches": 1,
228+
"canAccessPaidPatches": false,
229+
"packages": [
230+
{
231+
"purl": "pkg:npm/minimist@1.2.2",
232+
"patches": [
233+
{ "uuid": "", "purl": "pkg:npm/minimist@1.2.2", "tier": "free", "cveIds": ["CVE-…"], "ghsaIds": [], "severity": "high", "title": "" }
234+
]
235+
}
236+
],
237+
"updates": [
238+
{ "purl": "pkg:npm/foo@1.0", "oldUuid": "<previous>", "newUuid": "<newest>" }
239+
]
240+
}
241+
```
242+
243+
The `updates` array lists PURLs where the newest available patch UUID differs from the one currently recorded in `.socket/manifest.json`. Bots use this to drive "what would change" summaries without mutating anything.
244+
245+
### `scan``--apply` mode
246+
247+
When invoked as `scan --json --apply`, the discovery object above is augmented with a top-level `apply` sub-object reporting per-patch outcomes from the download + manifest write:
248+
249+
```json
250+
{
251+
"status": "success", // or "partial_failure"
252+
"scannedPackages": 42,
253+
// … all discovery fields above …
254+
"updates": [ ],
255+
"apply": {
256+
"found": 3,
257+
"downloaded": 2,
258+
"skipped": 1,
259+
"failed": 0,
260+
"applied": 2,
261+
"updated": 1,
262+
"patches": [
263+
{ "purl": "pkg:npm/foo@1.0", "uuid": "<new>", "action": "added" },
264+
{ "purl": "pkg:npm/bar@2.0", "uuid": "<new>", "action": "updated", "oldUuid": "<previous>" },
265+
{ "purl": "pkg:npm/baz@3.0", "uuid": "<existing>", "action": "skipped" },
266+
{ "purl": "pkg:npm/qux@4.0", "uuid": "<attempted>", "action": "failed", "error": "" }
267+
]
268+
}
269+
}
270+
```
271+
272+
Per-patch `action` vocabulary is stable:
273+
274+
| `action` | Meaning |
275+
|---|---|
276+
| `"added"` | PURL was not in the manifest before. |
277+
| `"updated"` | PURL was in the manifest with a different UUID. `oldUuid` is included. |
278+
| `"skipped"` | PURL was in the manifest with the same UUID. No work was done. |
279+
| `"failed"` | The patch could not be downloaded or saved. `error` is included. |
280+
281+
Exit code follows the apply outcome: `0` if every selected patch was added, updated, or skipped; `1` if any `failed` record is present (and `status` becomes `"partial_failure"`).
282+
215283
## Exit codes
216284

217285
| Code | Meaning |
@@ -235,11 +303,13 @@ Versioning lives in **`Cargo.toml`** at the workspace root (`version = "..."`) a
235303
| Change an exit code's meaning or add a new non-zero code with different semantics | **MAJOR** |
236304
| Rename a JSON output key or change a `status` string | **MAJOR** |
237305
| Remove a JSON output key | **MAJOR** |
306+
| Rename or remove a per-patch `action` value (`added`/`updated`/`skipped`/`failed`) | **MAJOR** |
238307
| Drop the bare-UUID fallback | **MAJOR** |
239308
| Add a *required* new flag | **MAJOR** |
240309
| Add a new subcommand | **MINOR** |
241310
| Add a new optional flag | **MINOR** |
242311
| Add a new optional JSON output key (additive) | **MINOR** |
312+
| Add a new value to a per-patch `action` enum (additive) | **MINOR** |
243313
| Add a new visible alias to an existing subcommand | **MINOR** |
244314
| Fix a bug without changing any of the above | **PATCH** |
245315

crates/socket-patch-cli/src/commands/get.rs

Lines changed: 121 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,36 @@ use std::path::PathBuf;
1616
use crate::ecosystem_dispatch::crawl_all_ecosystems;
1717
use crate::output::{confirm, select_one, SelectError};
1818

19+
/// Per-patch outcome reported in the JSON output of `download_and_apply_patches`.
20+
/// `Updated` carries the previous UUID so a bot can diff a manifest update against
21+
/// what was there before — see CLI_CONTRACT.md for the stable vocabulary.
22+
#[derive(Debug, PartialEq, Eq, Clone)]
23+
pub(crate) enum PatchAction {
24+
/// Patch did not exist in the manifest at this PURL.
25+
Added,
26+
/// Patch existed under this PURL with a different UUID; the new UUID
27+
/// replaces the old one. `old_uuid` is the UUID being overwritten.
28+
Updated { old_uuid: String },
29+
/// Patch already exists with the same UUID; download is a no-op.
30+
Skipped,
31+
}
32+
33+
/// Classify what `download_and_apply_patches` will do to a given PURL based on
34+
/// the manifest state *before* any insert. Pure / no I/O so it's unit-testable.
35+
pub(crate) fn decide_patch_action(
36+
manifest: &PatchManifest,
37+
purl: &str,
38+
new_uuid: &str,
39+
) -> PatchAction {
40+
match manifest.patches.get(purl) {
41+
Some(existing) if existing.uuid == new_uuid => PatchAction::Skipped,
42+
Some(existing) => PatchAction::Updated {
43+
old_uuid: existing.uuid.clone(),
44+
},
45+
None => PatchAction::Added,
46+
}
47+
}
48+
1949
#[derive(Args)]
2050
pub struct GetArgs {
2151
/// Patch identifier (UUID, CVE ID, GHSA ID, PURL, or package name)
@@ -335,12 +365,11 @@ pub async fn download_and_apply_patches(
335365
.await
336366
{
337367
Ok(Some(patch)) => {
338-
// Check if already in manifest with same UUID
339-
if manifest
340-
.patches
341-
.get(&patch.purl)
342-
.is_some_and(|p| p.uuid == patch.uuid)
343-
{
368+
// Classify against the manifest state BEFORE we touch it.
369+
// `Skipped` early-returns; `Updated` is preserved so the
370+
// per-patch JSON record below can include `oldUuid`.
371+
let action = decide_patch_action(&manifest, &patch.purl, &patch.uuid);
372+
if let PatchAction::Skipped = action {
344373
if !params.json && !params.silent {
345374
eprintln!(" [skip] {} (already in manifest)", patch.purl);
346375
}
@@ -458,14 +487,30 @@ pub async fn download_and_apply_patches(
458487
},
459488
);
460489

461-
if !params.json && !params.silent {
462-
eprintln!(" [add] {}", patch.purl);
463-
}
464-
downloaded_patches.push(serde_json::json!({
465-
"purl": patch.purl,
466-
"uuid": patch.uuid,
467-
"action": "added",
468-
}));
490+
let action_record = match &action {
491+
PatchAction::Updated { old_uuid } => {
492+
if !params.json && !params.silent {
493+
eprintln!(" [update] {}", patch.purl);
494+
}
495+
serde_json::json!({
496+
"purl": patch.purl,
497+
"uuid": patch.uuid,
498+
"action": "updated",
499+
"oldUuid": old_uuid,
500+
})
501+
}
502+
_ => {
503+
if !params.json && !params.silent {
504+
eprintln!(" [add] {}", patch.purl);
505+
}
506+
serde_json::json!({
507+
"purl": patch.purl,
508+
"uuid": patch.uuid,
509+
"action": "added",
510+
})
511+
}
512+
};
513+
downloaded_patches.push(action_record);
469514
patches_added += 1;
470515
}
471516
Ok(None) => {
@@ -1451,4 +1496,66 @@ mod tests {
14511496
assert_eq!(out[0].uuid, "free");
14521497
assert_eq!(out[0].tier, "free");
14531498
}
1499+
1500+
// --- decide_patch_action ---------------------------------------------
1501+
// Locks in the per-patch action vocabulary surfaced by
1502+
// download_and_apply_patches in JSON mode. See CLI_CONTRACT.md.
1503+
1504+
fn manifest_with_entry(purl: &str, uuid: &str) -> PatchManifest {
1505+
let mut m = PatchManifest::new();
1506+
m.patches.insert(
1507+
purl.to_string(),
1508+
PatchRecord {
1509+
uuid: uuid.to_string(),
1510+
exported_at: String::new(),
1511+
files: HashMap::new(),
1512+
vulnerabilities: HashMap::new(),
1513+
description: String::new(),
1514+
license: String::new(),
1515+
tier: "free".to_string(),
1516+
},
1517+
);
1518+
m
1519+
}
1520+
1521+
#[test]
1522+
fn decide_patch_action_added_when_purl_absent() {
1523+
let manifest = PatchManifest::new();
1524+
assert_eq!(
1525+
decide_patch_action(&manifest, "pkg:npm/foo@1.0", "uuid-a"),
1526+
PatchAction::Added,
1527+
);
1528+
}
1529+
1530+
#[test]
1531+
fn decide_patch_action_skipped_when_same_uuid() {
1532+
let manifest = manifest_with_entry("pkg:npm/foo@1.0", "uuid-a");
1533+
assert_eq!(
1534+
decide_patch_action(&manifest, "pkg:npm/foo@1.0", "uuid-a"),
1535+
PatchAction::Skipped,
1536+
);
1537+
}
1538+
1539+
#[test]
1540+
fn decide_patch_action_updated_when_different_uuid() {
1541+
let manifest = manifest_with_entry("pkg:npm/foo@1.0", "uuid-a");
1542+
assert_eq!(
1543+
decide_patch_action(&manifest, "pkg:npm/foo@1.0", "uuid-b"),
1544+
PatchAction::Updated {
1545+
old_uuid: "uuid-a".to_string()
1546+
},
1547+
);
1548+
}
1549+
1550+
#[test]
1551+
fn decide_patch_action_added_for_different_purl_even_with_overlapping_manifest() {
1552+
// Ensure update detection keys on PURL, not UUID. A new PURL with a
1553+
// UUID that happens to match an existing entry under a different
1554+
// PURL must still be `Added`.
1555+
let manifest = manifest_with_entry("pkg:npm/foo@1.0", "uuid-a");
1556+
assert_eq!(
1557+
decide_patch_action(&manifest, "pkg:npm/bar@2.0", "uuid-a"),
1558+
PatchAction::Added,
1559+
);
1560+
}
14541561
}

0 commit comments

Comments
 (0)