From ae05611ba807ec9e98939a7af6c0f030655dbe22 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 27 May 2026 14:27:13 +0100 Subject: [PATCH] =?UTF-8?q?feat(campaign):=20panic-attack=20campaign=20pol?= =?UTF-8?q?l=20=E2=80=94=20GitHub=20PR=20transitions=20(issue=20#33=20S2b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the S2 follow-up called out in #56. Adds a new subcommand: panic-attack campaign poll 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///pulls/. 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 requests (60/hour). Output: one line per transition. Implementation: - New campaign primitives: `transition(finding_id, new_state, pr_url, reason, base_dir)` — lower-level than `register_pr`/`dismiss`, stamps `last_polled`. - `parse_pr_url(url) -> ParsedPrUrl` — pure, accepts canonical `https://github.com///pull/` plus trailing slash and fragment. - `RemotePrState` + `should_transition` — pure mapping helpers that decide whether a fetched state warrants a new hexad. - `poll(base_dir) -> Vec` — orchestrator. - `fetch_remote_pr_state` — single ureq GET with correct headers (Accept, User-Agent, X-GitHub-Api-Version). The whole new section is gated on `#[cfg(feature = "http")]`; the default `cargo build` is unaffected, and `cargo build --features http` picks up the new subcommand. Tests (added under http feature): 11 new — 6 parse_pr_url cases (canonical, trailing slash, fragment, non-github reject, issue-url reject, missing-number reject); 4 should_transition cases (open→filed no-op, filed→merged, filed→closed, merged→merged no-op); 1 transition() writes a new hexad and the latest-state fold sees it. Full lib suite: 222 green default, 260 green with --features http. Clippy clean with --features http -D warnings. Refs #33. Stacked on #56 (S2). Diff against main includes the S2 changes until S2 lands; this PR rebases cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/campaign/mod.rs | 322 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 34 +++++ 2 files changed, 356 insertions(+) diff --git a/src/campaign/mod.rs b/src/campaign/mod.rs index 62be968..225ec84 100644 --- a/src/campaign/mod.rs +++ b/src/campaign/mod.rs @@ -63,6 +63,35 @@ pub fn register_pr(finding_id: &str, pr_url: &str, base_dir: &Path) -> Result, + reason: Option<&str>, + base_dir: &Path, +) -> Result { + if finding_id.is_empty() { + return Err(anyhow!("finding_id must not be empty")); + } + if new_state.is_empty() { + return Err(anyhow!("state must not be empty")); + } + let hexad = build_campaign_hexad(CampaignSemantic { + finding_id: finding_id.to_string(), + state: new_state.to_string(), + pr_url: pr_url.map(str::to_string), + reason: reason.map(str::to_string), + last_polled: Some(Utc::now().to_rfc3339()), + }); + write_campaign_hexad(&hexad, base_dir) +} + /// Dismiss a finding (parked, known-good, out-of-scope). /// /// Writes a `dismissed` campaign hexad. Returns the path written. @@ -217,6 +246,205 @@ pub fn status_markdown(base_dir: &Path) -> Result { Ok(out) } +// --------------------------------------------------------------------------- +// Issue #33 S2b — poll GitHub for PR state transitions +// +// The whole section is `#[cfg(feature = "http")]`: it depends on ureq +// (an optional dep) and on having any networking surface compiled in. +// --------------------------------------------------------------------------- + +/// Parsed GitHub PR URL components. +#[cfg(feature = "http")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedPrUrl { + pub owner: String, + pub repo: String, + pub number: u64, +} + +/// Parse a GitHub PR URL into `(owner, repo, number)`. +/// +/// Accepts the canonical form `https://github.com///pull/` +/// (with optional trailing slash or fragment). +#[cfg(feature = "http")] +pub fn parse_pr_url(url: &str) -> Result { + let trimmed = url.trim(); + let after_scheme = trimmed + .strip_prefix("https://github.com/") + .or_else(|| trimmed.strip_prefix("http://github.com/")) + .ok_or_else(|| anyhow!("not a github.com URL: {}", url))?; + let mut parts = after_scheme + .trim_end_matches('/') + .split('/') + .filter(|s| !s.is_empty()); + let owner = parts + .next() + .ok_or_else(|| anyhow!("missing owner in PR URL: {}", url))? + .to_string(); + let repo = parts + .next() + .ok_or_else(|| anyhow!("missing repo in PR URL: {}", url))? + .to_string(); + let kind = parts + .next() + .ok_or_else(|| anyhow!("missing 'pull' in PR URL: {}", url))?; + if kind != "pull" { + return Err(anyhow!("expected 'pull' segment, got '{}'", kind)); + } + let number_str = parts + .next() + .ok_or_else(|| anyhow!("missing PR number in URL: {}", url))?; + let number_only = number_str.split('#').next().unwrap_or(number_str); + let number: u64 = number_only + .parse() + .map_err(|_| anyhow!("PR number is not a positive integer: {}", number_str))?; + Ok(ParsedPrUrl { + owner, + repo, + number, + }) +} + +/// State derived from a GitHub PR API response. +#[cfg(feature = "http")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemotePrState { + /// PR is still open on GitHub. + Open, + /// PR was closed and merged. + Merged, + /// PR was closed without being merged. + Closed, +} + +#[cfg(feature = "http")] +impl RemotePrState { + /// Canonical campaign state label for this remote state. + pub fn campaign_state(self) -> &'static str { + match self { + RemotePrState::Open => state::PR_FILED, + RemotePrState::Merged => state::PR_MERGED, + RemotePrState::Closed => state::PR_CLOSED, + } + } +} + +/// Result of a single poll iteration. +#[cfg(feature = "http")] +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct PollOutcome { + pub finding_id: String, + pub pr_url: String, + pub old_state: String, + pub new_state: String, + /// True when a new campaign hexad was written. + pub transitioned: bool, +} + +/// Decide whether a remote-fetched state warrants writing a new +/// transition hexad. Pure — testable without network. +#[cfg(feature = "http")] +pub fn should_transition(current: &str, remote: RemotePrState) -> bool { + let target = remote.campaign_state(); + !current.eq_ignore_ascii_case(target) +} + +/// Poll GitHub for PR state and write transition hexads. +/// +/// For each finding whose latest campaign state is `pr-filed`, fetch +/// the PR's current state via the GitHub REST API and — if it has +/// changed — write a new campaign hexad (`pr-merged` / `pr-closed`). +/// +/// Auth: reads `GH_TOKEN` then `GITHUB_TOKEN` from the environment; +/// unauthenticated calls are accepted but capped at 60/hour by GitHub. +#[cfg(feature = "http")] +pub fn poll(base_dir: &Path) -> Result> { + let rows = current_state(base_dir)?; + let mut outcomes = Vec::new(); + for row in rows { + if row.state != state::PR_FILED { + continue; + } + let Some(ref url) = row.pr_url else { continue }; + let parsed = match parse_pr_url(url) { + Ok(p) => p, + Err(_) => continue, + }; + let remote = match fetch_remote_pr_state(&parsed) { + Ok(s) => s, + Err(_) => continue, + }; + let old_state = row.state.clone(); + let new_state_label = remote.campaign_state().to_string(); + let transitioned = should_transition(&row.state, remote); + if transitioned { + transition( + &row.finding_id, + remote.campaign_state(), + Some(url), + None, + base_dir, + )?; + } + outcomes.push(PollOutcome { + finding_id: row.finding_id, + pr_url: url.clone(), + old_state, + new_state: new_state_label, + transitioned, + }); + } + Ok(outcomes) +} + +/// Issue a single GET to the GitHub PR endpoint and map the response. +#[cfg(feature = "http")] +fn fetch_remote_pr_state(parsed: &ParsedPrUrl) -> Result { + use std::io::Read; + + let url = format!( + "https://api.github.com/repos/{}/{}/pulls/{}", + parsed.owner, parsed.repo, parsed.number + ); + let mut builder = ureq::get(&url) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "panic-attack-campaign-poll") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Ok(token) = std::env::var("GH_TOKEN").or_else(|_| std::env::var("GITHUB_TOKEN")) { + if !token.is_empty() { + builder = builder.header("Authorization", format!("Bearer {}", token)); + } + } + let mut response = builder + .call() + .map_err(|e| anyhow!("GitHub API request failed: {}", e))?; + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + return Err(anyhow!("GitHub API returned {}", status)); + } + let mut body = String::new(); + response + .body_mut() + .as_reader() + .take(4 * 1024 * 1024) + .read_to_string(&mut body) + .map_err(|e| anyhow!("reading GitHub PR response: {}", e))?; + let json: serde_json::Value = + serde_json::from_str(&body).map_err(|e| anyhow!("parsing GitHub PR response: {}", e))?; + let state_field = json.get("state").and_then(|v| v.as_str()).unwrap_or(""); + let merged_at = json.get("merged_at").and_then(|v| v.as_str()); + let merged = json + .get("merged") + .and_then(|v| v.as_bool()) + .unwrap_or(merged_at.is_some()); + Ok(match (state_field, merged) { + (_, true) => RemotePrState::Merged, + ("closed", false) => RemotePrState::Closed, + _ => RemotePrState::Open, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -293,4 +521,98 @@ mod tests { assert!(md.contains("test coverage gap")); assert!(md.contains("1 open, 1 dismissed")); } + + // ----- Issue #33 S2b: poll-related tests ------------------------------- + + #[cfg(feature = "http")] + #[test] + fn parse_pr_url_canonical() { + let p = parse_pr_url("https://github.com/foo/bar/pull/42").unwrap(); + assert_eq!(p.owner, "foo"); + assert_eq!(p.repo, "bar"); + assert_eq!(p.number, 42); + } + + #[cfg(feature = "http")] + #[test] + fn parse_pr_url_trailing_slash() { + let p = parse_pr_url("https://github.com/foo/bar/pull/42/").unwrap(); + assert_eq!(p.number, 42); + } + + #[cfg(feature = "http")] + #[test] + fn parse_pr_url_with_fragment() { + let p = parse_pr_url("https://github.com/foo/bar/pull/42#discussion_r1").unwrap(); + assert_eq!(p.number, 42); + } + + #[cfg(feature = "http")] + #[test] + fn parse_pr_url_rejects_non_github() { + assert!(parse_pr_url("https://gitlab.com/foo/bar/pull/42").is_err()); + } + + #[cfg(feature = "http")] + #[test] + fn parse_pr_url_rejects_issue_url() { + assert!(parse_pr_url("https://github.com/foo/bar/issues/42").is_err()); + } + + #[cfg(feature = "http")] + #[test] + fn parse_pr_url_rejects_missing_number() { + assert!(parse_pr_url("https://github.com/foo/bar/pull/").is_err()); + assert!(parse_pr_url("https://github.com/foo/bar/pull/abc").is_err()); + } + + #[cfg(feature = "http")] + #[test] + fn should_transition_open_to_filed_is_noop() { + assert!(!should_transition(state::PR_FILED, RemotePrState::Open)); + } + + #[cfg(feature = "http")] + #[test] + fn should_transition_filed_to_merged() { + assert!(should_transition(state::PR_FILED, RemotePrState::Merged)); + } + + #[cfg(feature = "http")] + #[test] + fn should_transition_filed_to_closed() { + assert!(should_transition(state::PR_FILED, RemotePrState::Closed)); + } + + #[cfg(feature = "http")] + #[test] + fn should_transition_already_merged_is_noop() { + assert!(!should_transition(state::PR_MERGED, RemotePrState::Merged)); + } + + #[test] + fn transition_writes_new_hexad() { + let dir = tempdir().unwrap(); + let finding_id = "finding:demo:src/a.rs:1:UnsafeCode"; + register_pr(finding_id, "https://github.com/x/y/pull/1", dir.path()).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + transition( + finding_id, + state::PR_MERGED, + Some("https://github.com/x/y/pull/1"), + None, + dir.path(), + ) + .unwrap(); + let rows = current_state(dir.path()).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, state::PR_MERGED); + } + + #[test] + fn transition_rejects_empty_args() { + let dir = tempdir().unwrap(); + assert!(transition("", state::PR_MERGED, None, None, dir.path()).is_err()); + assert!(transition("finding:x:y:1:Z", "", None, None, dir.path()).is_err()); + } } diff --git a/src/main.rs b/src/main.rs index c10e004..fd40afe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -851,6 +851,18 @@ enum CampaignAction { #[arg(short, long, value_name = "FILE")] output: Option, }, + + /// Poll GitHub for PR-state transitions on every `pr-filed` finding + /// and write new campaign hexads when state changes (issue #33 S2b). + /// + /// Requires the `http` feature. Reads auth token from `GH_TOKEN` or + /// `GITHUB_TOKEN` (falls back to unauthenticated, 60 req/hr). + #[cfg(feature = "http")] + Poll { + /// VeriSimDB data directory (default: `verisimdb-data`). + #[arg(long, value_name = "DIR", default_value = "verisimdb-data")] + verisimdb_dir: PathBuf, + }, } /// Patch Bridge subcommands for CVE lifecycle management. @@ -2513,6 +2525,28 @@ fn run_main() -> Result<()> { None => print!("{}", md), } } + #[cfg(feature = "http")] + CampaignAction::Poll { verisimdb_dir } => { + let outcomes = campaign::poll(&verisimdb_dir)?; + let transitioned = outcomes.iter().filter(|o| o.transitioned).count(); + qprintln!( + cli.quiet, + "Polled {} open findings, {} state transitions", + outcomes.len(), + transitioned + ); + for o in &outcomes { + if o.transitioned { + qprintln!( + cli.quiet, + " {} : {} -> {}", + o.finding_id, + o.old_state, + o.new_state + ); + } + } + } } return Ok(()); }