@@ -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) ]
221449mod 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}
0 commit comments