Skip to content

Commit acd329f

Browse files
feat(campaign): panic-attack campaign poll — GitHub PR transitions (issue #33 S2b) (#60)
## Summary Closes the S2 follow-up called out in #56. Adds a new subcommand under \`panic-attack campaign\`: \`\`\` panic-attack campaign poll [--verisimdb-dir DIR] \`\`\` For each finding whose latest campaign state is \`pr-filed\`, the poller: 1. Parses the stored PR URL into \`(owner, repo, number)\`. 2. Calls \`GET https://api.github.com/repos/<o>/<r>/pulls/<n>\`. 3. Maps the response to \`RemotePrState::{Open, Merged, Closed}\`. 4. If the new state differs from \`pr-filed\`, writes a new campaign hexad stamping \`last_polled\` to the current timestamp. Auth: reads \`GH_TOKEN\` or \`GITHUB_TOKEN\` from the environment. Falls back to unauthenticated (GitHub 60/hour cap). Output: one line per transition. > Stacked on #56 — diff against \`main\` includes the S2 changes until S2 lands; this PR rebases cleanly. ## New surface | Item | Purpose | |---|---| | \`transition(finding_id, new_state, pr_url, reason, dir)\` | Lower-level than \`register_pr\`/\`dismiss\`; stamps \`last_polled\`. | | \`parse_pr_url(url) -> ParsedPrUrl\` | Pure parser; accepts canonical PR URL + trailing slash + fragment. | | \`RemotePrState\` + \`should_transition\` | Pure helpers — decide whether a fetched state warrants a new hexad. | | \`poll(base_dir) -> Vec<PollOutcome>\` | Orchestrator. | | \`fetch_remote_pr_state\` | Single ureq GET with correct headers (\`Accept\`, \`User-Agent\`, \`X-GitHub-Api-Version\`). | ## Feature gating The whole new section is \`#[cfg(feature = "http")]\`. Default \`cargo build\` is unaffected. \`cargo build --features http\` picks up the new subcommand. ## Test plan - [x] \`cargo test --lib\` — 222 green (default). - [x] \`cargo test --lib --features http\` — 260 green (11 new poll tests). - [x] \`cargo clippy --all-targets --features http -- -D warnings\` — clean. - [x] \`cargo fmt --all\` — clean. 11 new tests cover: - 6 \`parse_pr_url\` cases (canonical, trailing slash, fragment, non-github, issue-url, missing-number). - 4 \`should_transition\` cases (open→filed no-op, filed→merged, filed→closed, merged→merged no-op). - 1 end-to-end \`transition()\` + latest-state fold. Refs #33. Stacked on #56 (S2). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5e86c1e commit acd329f

2 files changed

Lines changed: 356 additions & 0 deletions

File tree

src/campaign/mod.rs

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,35 @@ pub fn register_pr(finding_id: &str, pr_url: &str, base_dir: &Path) -> Result<st
6363
write_campaign_hexad(&hexad, base_dir)
6464
}
6565

66+
/// Write an arbitrary state transition hexad.
67+
///
68+
/// Lower-level than `register_pr` / `dismiss` — callers supply the full
69+
/// state, optional PR url, and optional reason. Used by `poll` to
70+
/// promote a finding from `pr-filed` to `pr-merged` / `pr-closed`.
71+
#[allow(dead_code)]
72+
pub fn transition(
73+
finding_id: &str,
74+
new_state: &str,
75+
pr_url: Option<&str>,
76+
reason: Option<&str>,
77+
base_dir: &Path,
78+
) -> Result<std::path::PathBuf> {
79+
if finding_id.is_empty() {
80+
return Err(anyhow!("finding_id must not be empty"));
81+
}
82+
if new_state.is_empty() {
83+
return Err(anyhow!("state must not be empty"));
84+
}
85+
let hexad = build_campaign_hexad(CampaignSemantic {
86+
finding_id: finding_id.to_string(),
87+
state: new_state.to_string(),
88+
pr_url: pr_url.map(str::to_string),
89+
reason: reason.map(str::to_string),
90+
last_polled: Some(Utc::now().to_rfc3339()),
91+
});
92+
write_campaign_hexad(&hexad, base_dir)
93+
}
94+
6695
/// Dismiss a finding (parked, known-good, out-of-scope).
6796
///
6897
/// Writes a `dismissed` campaign hexad. Returns the path written.
@@ -217,6 +246,205 @@ pub fn status_markdown(base_dir: &Path) -> Result<String> {
217246
Ok(out)
218247
}
219248

249+
// ---------------------------------------------------------------------------
250+
// Issue #33 S2b — poll GitHub for PR state transitions
251+
//
252+
// The whole section is `#[cfg(feature = "http")]`: it depends on ureq
253+
// (an optional dep) and on having any networking surface compiled in.
254+
// ---------------------------------------------------------------------------
255+
256+
/// Parsed GitHub PR URL components.
257+
#[cfg(feature = "http")]
258+
#[derive(Debug, Clone, PartialEq, Eq)]
259+
pub struct ParsedPrUrl {
260+
pub owner: String,
261+
pub repo: String,
262+
pub number: u64,
263+
}
264+
265+
/// Parse a GitHub PR URL into `(owner, repo, number)`.
266+
///
267+
/// Accepts the canonical form `https://github.com/<owner>/<repo>/pull/<n>`
268+
/// (with optional trailing slash or fragment).
269+
#[cfg(feature = "http")]
270+
pub fn parse_pr_url(url: &str) -> Result<ParsedPrUrl> {
271+
let trimmed = url.trim();
272+
let after_scheme = trimmed
273+
.strip_prefix("https://github.com/")
274+
.or_else(|| trimmed.strip_prefix("http://github.com/"))
275+
.ok_or_else(|| anyhow!("not a github.com URL: {}", url))?;
276+
let mut parts = after_scheme
277+
.trim_end_matches('/')
278+
.split('/')
279+
.filter(|s| !s.is_empty());
280+
let owner = parts
281+
.next()
282+
.ok_or_else(|| anyhow!("missing owner in PR URL: {}", url))?
283+
.to_string();
284+
let repo = parts
285+
.next()
286+
.ok_or_else(|| anyhow!("missing repo in PR URL: {}", url))?
287+
.to_string();
288+
let kind = parts
289+
.next()
290+
.ok_or_else(|| anyhow!("missing 'pull' in PR URL: {}", url))?;
291+
if kind != "pull" {
292+
return Err(anyhow!("expected 'pull' segment, got '{}'", kind));
293+
}
294+
let number_str = parts
295+
.next()
296+
.ok_or_else(|| anyhow!("missing PR number in URL: {}", url))?;
297+
let number_only = number_str.split('#').next().unwrap_or(number_str);
298+
let number: u64 = number_only
299+
.parse()
300+
.map_err(|_| anyhow!("PR number is not a positive integer: {}", number_str))?;
301+
Ok(ParsedPrUrl {
302+
owner,
303+
repo,
304+
number,
305+
})
306+
}
307+
308+
/// State derived from a GitHub PR API response.
309+
#[cfg(feature = "http")]
310+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311+
pub enum RemotePrState {
312+
/// PR is still open on GitHub.
313+
Open,
314+
/// PR was closed and merged.
315+
Merged,
316+
/// PR was closed without being merged.
317+
Closed,
318+
}
319+
320+
#[cfg(feature = "http")]
321+
impl RemotePrState {
322+
/// Canonical campaign state label for this remote state.
323+
pub fn campaign_state(self) -> &'static str {
324+
match self {
325+
RemotePrState::Open => state::PR_FILED,
326+
RemotePrState::Merged => state::PR_MERGED,
327+
RemotePrState::Closed => state::PR_CLOSED,
328+
}
329+
}
330+
}
331+
332+
/// Result of a single poll iteration.
333+
#[cfg(feature = "http")]
334+
#[allow(dead_code)]
335+
#[derive(Debug, Clone)]
336+
pub struct PollOutcome {
337+
pub finding_id: String,
338+
pub pr_url: String,
339+
pub old_state: String,
340+
pub new_state: String,
341+
/// True when a new campaign hexad was written.
342+
pub transitioned: bool,
343+
}
344+
345+
/// Decide whether a remote-fetched state warrants writing a new
346+
/// transition hexad. Pure — testable without network.
347+
#[cfg(feature = "http")]
348+
pub fn should_transition(current: &str, remote: RemotePrState) -> bool {
349+
let target = remote.campaign_state();
350+
!current.eq_ignore_ascii_case(target)
351+
}
352+
353+
/// Poll GitHub for PR state and write transition hexads.
354+
///
355+
/// For each finding whose latest campaign state is `pr-filed`, fetch
356+
/// the PR's current state via the GitHub REST API and — if it has
357+
/// changed — write a new campaign hexad (`pr-merged` / `pr-closed`).
358+
///
359+
/// Auth: reads `GH_TOKEN` then `GITHUB_TOKEN` from the environment;
360+
/// unauthenticated calls are accepted but capped at 60/hour by GitHub.
361+
#[cfg(feature = "http")]
362+
pub fn poll(base_dir: &Path) -> Result<Vec<PollOutcome>> {
363+
let rows = current_state(base_dir)?;
364+
let mut outcomes = Vec::new();
365+
for row in rows {
366+
if row.state != state::PR_FILED {
367+
continue;
368+
}
369+
let Some(ref url) = row.pr_url else { continue };
370+
let parsed = match parse_pr_url(url) {
371+
Ok(p) => p,
372+
Err(_) => continue,
373+
};
374+
let remote = match fetch_remote_pr_state(&parsed) {
375+
Ok(s) => s,
376+
Err(_) => continue,
377+
};
378+
let old_state = row.state.clone();
379+
let new_state_label = remote.campaign_state().to_string();
380+
let transitioned = should_transition(&row.state, remote);
381+
if transitioned {
382+
transition(
383+
&row.finding_id,
384+
remote.campaign_state(),
385+
Some(url),
386+
None,
387+
base_dir,
388+
)?;
389+
}
390+
outcomes.push(PollOutcome {
391+
finding_id: row.finding_id,
392+
pr_url: url.clone(),
393+
old_state,
394+
new_state: new_state_label,
395+
transitioned,
396+
});
397+
}
398+
Ok(outcomes)
399+
}
400+
401+
/// Issue a single GET to the GitHub PR endpoint and map the response.
402+
#[cfg(feature = "http")]
403+
fn fetch_remote_pr_state(parsed: &ParsedPrUrl) -> Result<RemotePrState> {
404+
use std::io::Read;
405+
406+
let url = format!(
407+
"https://api.github.com/repos/{}/{}/pulls/{}",
408+
parsed.owner, parsed.repo, parsed.number
409+
);
410+
let mut builder = ureq::get(&url)
411+
.header("Accept", "application/vnd.github+json")
412+
.header("User-Agent", "panic-attack-campaign-poll")
413+
.header("X-GitHub-Api-Version", "2022-11-28");
414+
if let Ok(token) = std::env::var("GH_TOKEN").or_else(|_| std::env::var("GITHUB_TOKEN")) {
415+
if !token.is_empty() {
416+
builder = builder.header("Authorization", format!("Bearer {}", token));
417+
}
418+
}
419+
let mut response = builder
420+
.call()
421+
.map_err(|e| anyhow!("GitHub API request failed: {}", e))?;
422+
let status = response.status().as_u16();
423+
if !(200..300).contains(&status) {
424+
return Err(anyhow!("GitHub API returned {}", status));
425+
}
426+
let mut body = String::new();
427+
response
428+
.body_mut()
429+
.as_reader()
430+
.take(4 * 1024 * 1024)
431+
.read_to_string(&mut body)
432+
.map_err(|e| anyhow!("reading GitHub PR response: {}", e))?;
433+
let json: serde_json::Value =
434+
serde_json::from_str(&body).map_err(|e| anyhow!("parsing GitHub PR response: {}", e))?;
435+
let state_field = json.get("state").and_then(|v| v.as_str()).unwrap_or("");
436+
let merged_at = json.get("merged_at").and_then(|v| v.as_str());
437+
let merged = json
438+
.get("merged")
439+
.and_then(|v| v.as_bool())
440+
.unwrap_or(merged_at.is_some());
441+
Ok(match (state_field, merged) {
442+
(_, true) => RemotePrState::Merged,
443+
("closed", false) => RemotePrState::Closed,
444+
_ => RemotePrState::Open,
445+
})
446+
}
447+
220448
#[cfg(test)]
221449
mod tests {
222450
use super::*;
@@ -293,4 +521,98 @@ mod tests {
293521
assert!(md.contains("test coverage gap"));
294522
assert!(md.contains("1 open, 1 dismissed"));
295523
}
524+
525+
// ----- Issue #33 S2b: poll-related tests -------------------------------
526+
527+
#[cfg(feature = "http")]
528+
#[test]
529+
fn parse_pr_url_canonical() {
530+
let p = parse_pr_url("https://github.com/foo/bar/pull/42").unwrap();
531+
assert_eq!(p.owner, "foo");
532+
assert_eq!(p.repo, "bar");
533+
assert_eq!(p.number, 42);
534+
}
535+
536+
#[cfg(feature = "http")]
537+
#[test]
538+
fn parse_pr_url_trailing_slash() {
539+
let p = parse_pr_url("https://github.com/foo/bar/pull/42/").unwrap();
540+
assert_eq!(p.number, 42);
541+
}
542+
543+
#[cfg(feature = "http")]
544+
#[test]
545+
fn parse_pr_url_with_fragment() {
546+
let p = parse_pr_url("https://github.com/foo/bar/pull/42#discussion_r1").unwrap();
547+
assert_eq!(p.number, 42);
548+
}
549+
550+
#[cfg(feature = "http")]
551+
#[test]
552+
fn parse_pr_url_rejects_non_github() {
553+
assert!(parse_pr_url("https://gitlab.com/foo/bar/pull/42").is_err());
554+
}
555+
556+
#[cfg(feature = "http")]
557+
#[test]
558+
fn parse_pr_url_rejects_issue_url() {
559+
assert!(parse_pr_url("https://github.com/foo/bar/issues/42").is_err());
560+
}
561+
562+
#[cfg(feature = "http")]
563+
#[test]
564+
fn parse_pr_url_rejects_missing_number() {
565+
assert!(parse_pr_url("https://github.com/foo/bar/pull/").is_err());
566+
assert!(parse_pr_url("https://github.com/foo/bar/pull/abc").is_err());
567+
}
568+
569+
#[cfg(feature = "http")]
570+
#[test]
571+
fn should_transition_open_to_filed_is_noop() {
572+
assert!(!should_transition(state::PR_FILED, RemotePrState::Open));
573+
}
574+
575+
#[cfg(feature = "http")]
576+
#[test]
577+
fn should_transition_filed_to_merged() {
578+
assert!(should_transition(state::PR_FILED, RemotePrState::Merged));
579+
}
580+
581+
#[cfg(feature = "http")]
582+
#[test]
583+
fn should_transition_filed_to_closed() {
584+
assert!(should_transition(state::PR_FILED, RemotePrState::Closed));
585+
}
586+
587+
#[cfg(feature = "http")]
588+
#[test]
589+
fn should_transition_already_merged_is_noop() {
590+
assert!(!should_transition(state::PR_MERGED, RemotePrState::Merged));
591+
}
592+
593+
#[test]
594+
fn transition_writes_new_hexad() {
595+
let dir = tempdir().unwrap();
596+
let finding_id = "finding:demo:src/a.rs:1:UnsafeCode";
597+
register_pr(finding_id, "https://github.com/x/y/pull/1", dir.path()).unwrap();
598+
std::thread::sleep(std::time::Duration::from_millis(1100));
599+
transition(
600+
finding_id,
601+
state::PR_MERGED,
602+
Some("https://github.com/x/y/pull/1"),
603+
None,
604+
dir.path(),
605+
)
606+
.unwrap();
607+
let rows = current_state(dir.path()).unwrap();
608+
assert_eq!(rows.len(), 1);
609+
assert_eq!(rows[0].state, state::PR_MERGED);
610+
}
611+
612+
#[test]
613+
fn transition_rejects_empty_args() {
614+
let dir = tempdir().unwrap();
615+
assert!(transition("", state::PR_MERGED, None, None, dir.path()).is_err());
616+
assert!(transition("finding:x:y:1:Z", "", None, None, dir.path()).is_err());
617+
}
296618
}

src/main.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -851,6 +851,18 @@ enum CampaignAction {
851851
#[arg(short, long, value_name = "FILE")]
852852
output: Option<PathBuf>,
853853
},
854+
855+
/// Poll GitHub for PR-state transitions on every `pr-filed` finding
856+
/// and write new campaign hexads when state changes (issue #33 S2b).
857+
///
858+
/// Requires the `http` feature. Reads auth token from `GH_TOKEN` or
859+
/// `GITHUB_TOKEN` (falls back to unauthenticated, 60 req/hr).
860+
#[cfg(feature = "http")]
861+
Poll {
862+
/// VeriSimDB data directory (default: `verisimdb-data`).
863+
#[arg(long, value_name = "DIR", default_value = "verisimdb-data")]
864+
verisimdb_dir: PathBuf,
865+
},
854866
}
855867

856868
/// Patch Bridge subcommands for CVE lifecycle management.
@@ -2513,6 +2525,28 @@ fn run_main() -> Result<()> {
25132525
None => print!("{}", md),
25142526
}
25152527
}
2528+
#[cfg(feature = "http")]
2529+
CampaignAction::Poll { verisimdb_dir } => {
2530+
let outcomes = campaign::poll(&verisimdb_dir)?;
2531+
let transitioned = outcomes.iter().filter(|o| o.transitioned).count();
2532+
qprintln!(
2533+
cli.quiet,
2534+
"Polled {} open findings, {} state transitions",
2535+
outcomes.len(),
2536+
transitioned
2537+
);
2538+
for o in &outcomes {
2539+
if o.transitioned {
2540+
qprintln!(
2541+
cli.quiet,
2542+
" {} : {} -> {}",
2543+
o.finding_id,
2544+
o.old_state,
2545+
o.new_state
2546+
);
2547+
}
2548+
}
2549+
}
25162550
}
25172551
return Ok(());
25182552
}

0 commit comments

Comments
 (0)