Skip to content

Commit d6e41dc

Browse files
hyperpolymathclaude
andcommitted
feat(campaign): finding-lifecycle CLI + state hexads (issue #33 S2)
Adds the second slice of issue #33: a panic-attack campaign subcommand that tracks the lifecycle of individual findings produced by the assemblyline per-finding hexad path (S1). State is persisted as campaign-facet hexads written under <dir>/hexads/campaign/, indexed by finding_id, append-only — the current state per finding is the newest campaign hexad with that finding_id as subject. New surface: - HexadSemantic gains `campaign: Option<CampaignSemantic>` (additive, skip_serializing_if = none). - CampaignSemantic { finding_id, state, pr_url?, reason?, last_polled? } — state is a free-form String so future labels can be added without a schema bump. - storage: build_campaign_hexad / write_campaign_hexad / load_{finding,campaign,aggregate}_hexads helpers. - src/campaign/ module — register_pr, dismiss, current_state, status_markdown. - panic-attack campaign register-pr|dismiss|status — CLI surface. `status` renders a Markdown tracker matching the shape of the issue #32 manual checklist: summary line, table with finding-id, repo, rule_id, location, state, PR link (or dismissal reason), last-event timestamp, checkbox column. Out of scope (S2b): poll subcommand that queries GitHub for PR-state transitions. The data path is in place — the polling logic lands once the rate-limit / pagination shape is settled. Tests: 5 new in src/campaign/ (register, dismiss-overrides-open, empty-arg rejection, empty-store status, two-row render). Full lib suite: 220 green. Clippy clean with -D warnings. End-to-end CLI smoke test green: register-pr + dismiss + status round-trip prints the expected markdown. Refs #33. Stacked on #55 (S1) — diff against main includes the S1 changes until S1 lands; this PR will rebase clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 070a9f8 commit d6e41dc

4 files changed

Lines changed: 542 additions & 0 deletions

File tree

src/campaign/mod.rs

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
3+
//! Campaign-state orchestration (issue #33 S2).
4+
//!
5+
//! Tracks the lifecycle of individual findings emitted by the assemblyline
6+
//! per-finding hexad path (issue #33 S1):
7+
//!
8+
//! - `register_pr(finding_id, pr_url)` — opens a PR for a finding.
9+
//! - `dismiss(finding_id, reason)` — marks a finding parked / known-good /
10+
//! intentionally-out-of-scope.
11+
//! - `status_markdown(base_dir)` — renders a Markdown tracker identical
12+
//! in shape to the manual checklist used in issue #32.
13+
//!
14+
//! State is persisted as campaign-facet hexads written under
15+
//! `<base_dir>/hexads/campaign/`. The store is append-only: each call
16+
//! writes a *new* hexad. `status` derives the current state per
17+
//! `finding_id` by sorting all campaign hexads by `created_at` and
18+
//! keeping the newest one for each subject.
19+
//!
20+
//! Polling GitHub for PR-state updates is deferred to a follow-up slice
21+
//! (S2b) — this initial S2 focuses on the local lifecycle primitives so
22+
//! the campaign data can accumulate before the polling logic lands.
23+
24+
use crate::storage::{
25+
build_campaign_hexad, load_campaign_hexads, load_finding_hexads, write_campaign_hexad,
26+
CampaignSemantic, PanicAttackHexad,
27+
};
28+
use anyhow::{anyhow, Result};
29+
use chrono::Utc;
30+
use std::collections::HashMap;
31+
use std::path::Path;
32+
33+
/// Canonical state labels written into `CampaignSemantic.state`.
34+
///
35+
/// New variants can be added without breaking older readers — the field
36+
/// is a `String` on the wire (forward-compatible by design).
37+
pub mod state {
38+
pub const OPEN: &str = "open";
39+
pub const PR_FILED: &str = "pr-filed";
40+
pub const PR_MERGED: &str = "pr-merged";
41+
pub const PR_CLOSED: &str = "pr-closed";
42+
pub const DISMISSED: &str = "dismissed";
43+
}
44+
45+
/// Register an open PR against a known finding.
46+
///
47+
/// Writes a `pr-filed` campaign hexad to `<base_dir>/hexads/campaign/`.
48+
/// Returns the path written.
49+
pub fn register_pr(finding_id: &str, pr_url: &str, base_dir: &Path) -> Result<std::path::PathBuf> {
50+
if finding_id.is_empty() {
51+
return Err(anyhow!("finding_id must not be empty"));
52+
}
53+
if pr_url.is_empty() {
54+
return Err(anyhow!("pr_url must not be empty"));
55+
}
56+
let hexad = build_campaign_hexad(CampaignSemantic {
57+
finding_id: finding_id.to_string(),
58+
state: state::PR_FILED.to_string(),
59+
pr_url: Some(pr_url.to_string()),
60+
reason: None,
61+
last_polled: None,
62+
});
63+
write_campaign_hexad(&hexad, base_dir)
64+
}
65+
66+
/// Dismiss a finding (parked, known-good, out-of-scope).
67+
///
68+
/// Writes a `dismissed` campaign hexad. Returns the path written.
69+
pub fn dismiss(finding_id: &str, reason: &str, base_dir: &Path) -> Result<std::path::PathBuf> {
70+
if finding_id.is_empty() {
71+
return Err(anyhow!("finding_id must not be empty"));
72+
}
73+
let hexad = build_campaign_hexad(CampaignSemantic {
74+
finding_id: finding_id.to_string(),
75+
state: state::DISMISSED.to_string(),
76+
pr_url: None,
77+
reason: Some(reason.to_string()),
78+
last_polled: None,
79+
});
80+
write_campaign_hexad(&hexad, base_dir)
81+
}
82+
83+
/// One row of the campaign tracker — current state of a finding.
84+
#[derive(Debug, Clone)]
85+
pub struct CampaignRow {
86+
pub finding_id: String,
87+
pub state: String,
88+
pub pr_url: Option<String>,
89+
pub reason: Option<String>,
90+
pub last_event_at: String,
91+
/// If the finding hexad is available, its repo name (for display).
92+
pub repo_name: Option<String>,
93+
/// Same — rule id (e.g. PA004).
94+
pub rule_id: Option<String>,
95+
/// Same — file:line summary.
96+
pub location: Option<String>,
97+
}
98+
99+
/// Compute the current campaign state for every finding seen, by
100+
/// folding the append-only hexad stream by `finding_id` and keeping the
101+
/// newest event.
102+
pub fn current_state(base_dir: &Path) -> Result<Vec<CampaignRow>> {
103+
let mut campaign = load_campaign_hexads(base_dir)?;
104+
campaign.sort_by(|a, b| a.created_at.cmp(&b.created_at));
105+
106+
// Index finding metadata by finding_id (latest wins, but for findings
107+
// the schema is run-stable so any matching hexad will do).
108+
let findings = load_finding_hexads(base_dir)?;
109+
let mut finding_meta: HashMap<String, &PanicAttackHexad> = HashMap::new();
110+
for h in &findings {
111+
if let Some(f) = h.semantic.finding.as_ref() {
112+
finding_meta.insert(f.finding_id.clone(), h);
113+
}
114+
}
115+
116+
let mut latest: HashMap<String, (String, CampaignSemantic)> = HashMap::new();
117+
for h in campaign {
118+
if let Some(c) = h.semantic.campaign.clone() {
119+
latest.insert(c.finding_id.clone(), (h.created_at.clone(), c));
120+
}
121+
}
122+
123+
let mut rows: Vec<CampaignRow> = latest
124+
.into_iter()
125+
.map(|(_, (ts, c))| {
126+
let (repo_name, rule_id, location) = finding_meta
127+
.get(&c.finding_id)
128+
.and_then(|h| h.semantic.finding.as_ref())
129+
.map(|f| {
130+
(
131+
Some(f.repo_name.clone()),
132+
Some(f.rule_id.clone()),
133+
Some(format!(
134+
"{}:{}",
135+
f.file,
136+
f.line.map(|n| n.to_string()).unwrap_or_default()
137+
)),
138+
)
139+
})
140+
.unwrap_or((None, None, None));
141+
CampaignRow {
142+
finding_id: c.finding_id,
143+
state: c.state,
144+
pr_url: c.pr_url,
145+
reason: c.reason,
146+
last_event_at: ts,
147+
repo_name,
148+
rule_id,
149+
location,
150+
}
151+
})
152+
.collect();
153+
rows.sort_by(|a, b| a.finding_id.cmp(&b.finding_id));
154+
Ok(rows)
155+
}
156+
157+
/// Render a Markdown tracker matching the shape used by issue #32.
158+
///
159+
/// Rows sorted by `finding_id`; checkbox `[x]` for merged/closed/dismissed,
160+
/// `[ ]` otherwise. State, PR link (or reason), and timestamp appear in
161+
/// columns. An ungrouped "Findings without campaign state" footer is
162+
/// omitted from S2 to keep the output small; S3 query is the right place
163+
/// to list "open work not yet PR'd".
164+
pub fn status_markdown(base_dir: &Path) -> Result<String> {
165+
let rows = current_state(base_dir)?;
166+
let now = Utc::now().to_rfc3339();
167+
let mut out = String::new();
168+
out.push_str(&format!(
169+
"# Campaign tracker — `panic-attack`\n\n_Generated {now}_\n\n"
170+
));
171+
if rows.is_empty() {
172+
out.push_str("_No campaign state recorded yet._\n");
173+
return Ok(out);
174+
}
175+
176+
let merged_count = rows
177+
.iter()
178+
.filter(|r| matches!(r.state.as_str(), state::PR_MERGED | state::PR_CLOSED))
179+
.count();
180+
let open_count = rows
181+
.iter()
182+
.filter(|r| matches!(r.state.as_str(), state::PR_FILED | state::OPEN))
183+
.count();
184+
let dismissed_count = rows.iter().filter(|r| r.state == state::DISMISSED).count();
185+
out.push_str(&format!(
186+
"**Summary**: {} merged/closed, {} open, {} dismissed (total {}).\n\n",
187+
merged_count,
188+
open_count,
189+
dismissed_count,
190+
rows.len()
191+
));
192+
193+
out.push_str("| ☐ | Finding | Repo | Rule | Location | State | PR / Reason | Last event |\n");
194+
out.push_str("|---|---------|------|------|----------|-------|-------------|------------|\n");
195+
for r in rows {
196+
let check = match r.state.as_str() {
197+
state::PR_MERGED | state::PR_CLOSED | state::DISMISSED => "[x]",
198+
_ => "[ ]",
199+
};
200+
let pr_or_reason = match (r.pr_url.as_deref(), r.reason.as_deref()) {
201+
(Some(url), _) => format!("[PR]({url})"),
202+
(None, Some(reason)) => reason.to_string(),
203+
(None, None) => "—".to_string(),
204+
};
205+
out.push_str(&format!(
206+
"| {} | `{}` | {} | {} | {} | {} | {} | {} |\n",
207+
check,
208+
r.finding_id,
209+
r.repo_name.as_deref().unwrap_or("—"),
210+
r.rule_id.as_deref().unwrap_or("—"),
211+
r.location.as_deref().unwrap_or("—"),
212+
r.state,
213+
pr_or_reason,
214+
r.last_event_at,
215+
));
216+
}
217+
Ok(out)
218+
}
219+
220+
#[cfg(test)]
221+
mod tests {
222+
use super::*;
223+
use tempfile::tempdir;
224+
225+
#[test]
226+
fn register_pr_writes_hexad() {
227+
let dir = tempdir().unwrap();
228+
let path = register_pr(
229+
"finding:demo:src/a.rs:1:UnsafeCode",
230+
"https://example.invalid/pr/1",
231+
dir.path(),
232+
)
233+
.expect("register ok");
234+
assert!(path.exists());
235+
let rows = current_state(dir.path()).unwrap();
236+
assert_eq!(rows.len(), 1);
237+
assert_eq!(rows[0].state, state::PR_FILED);
238+
assert_eq!(
239+
rows[0].pr_url.as_deref(),
240+
Some("https://example.invalid/pr/1")
241+
);
242+
}
243+
244+
#[test]
245+
fn dismiss_overrides_open() {
246+
let dir = tempdir().unwrap();
247+
let id = "finding:demo:src/a.rs:1:UnsafeCode";
248+
register_pr(id, "https://example.invalid/pr/1", dir.path()).unwrap();
249+
// Sleep a hair to ensure the second hexad's created_at sorts strictly later.
250+
std::thread::sleep(std::time::Duration::from_millis(1100));
251+
dismiss(id, "intentional sentinel", dir.path()).unwrap();
252+
let rows = current_state(dir.path()).unwrap();
253+
assert_eq!(rows.len(), 1, "one finding, latest state wins");
254+
assert_eq!(rows[0].state, state::DISMISSED);
255+
assert_eq!(rows[0].reason.as_deref(), Some("intentional sentinel"));
256+
}
257+
258+
#[test]
259+
fn register_pr_rejects_empty_args() {
260+
let dir = tempdir().unwrap();
261+
assert!(register_pr("", "https://example.invalid", dir.path()).is_err());
262+
assert!(register_pr("finding:x:y:1:Z", "", dir.path()).is_err());
263+
}
264+
265+
#[test]
266+
fn status_markdown_handles_empty() {
267+
let dir = tempdir().unwrap();
268+
let md = status_markdown(dir.path()).unwrap();
269+
assert!(md.contains("No campaign state recorded yet"));
270+
}
271+
272+
#[test]
273+
fn status_markdown_renders_rows() {
274+
let dir = tempdir().unwrap();
275+
register_pr(
276+
"finding:alpha:src/a.rs:1:UnsafeCode",
277+
"https://example.invalid/pr/1",
278+
dir.path(),
279+
)
280+
.unwrap();
281+
std::thread::sleep(std::time::Duration::from_millis(1100));
282+
dismiss(
283+
"finding:beta:src/b.rs:9:PanicPath",
284+
"test coverage gap",
285+
dir.path(),
286+
)
287+
.unwrap();
288+
let md = status_markdown(dir.path()).unwrap();
289+
assert!(md.contains("finding:alpha:src/a.rs:1:UnsafeCode"));
290+
assert!(md.contains("finding:beta:src/b.rs:9:PanicPath"));
291+
assert!(md.contains("pr-filed"));
292+
assert!(md.contains("dismissed"));
293+
assert!(md.contains("test coverage gap"));
294+
assert!(md.contains("1 open, 1 dismissed"));
295+
}
296+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ pub mod attestation;
2626
pub mod axial;
2727
#[cfg(feature = "http")]
2828
pub mod bridge;
29+
pub mod campaign;
2930
pub mod i18n;
3031
pub mod kanren;
3132
pub mod mass_panic;

0 commit comments

Comments
 (0)