1414from conviso .clients .client_graphql import graphql_request
1515from conviso .core .output_manager import export_data
1616from conviso .schemas .vulnerabilities_schema import schema
17+ from conviso .schemas .vulnerabilities_timeline_schema import timeline_schema , timeline_last_schema
1718
1819app = typer .Typer (help = "List and manage vulnerabilities (WEB, NETWORK, SOURCE)." )
1920
@@ -478,7 +479,9 @@ def _extract_status_change_fields(history_item: dict) -> tuple[str, str, str]:
478479
479480@app .command ("timeline" , help = "Show vulnerability timeline/history and filter by actor/status." )
480481def vulnerability_timeline (
481- issue_id : int = typer .Option (..., "--id" , "-i" , help = "Vulnerability/issue ID." ),
482+ issue_id : Optional [int ] = typer .Option (None , "--id" , "-i" , help = "Vulnerability/issue ID." ),
483+ company_id : Optional [int ] = typer .Option (None , "--company-id" , "-c" , help = "Company ID (required with --project-id)." ),
484+ project_id : Optional [int ] = typer .Option (None , "--project-id" , "-P" , help = "Project ID to aggregate timelines from related vulnerabilities." ),
482485 user_email : Optional [str ] = typer .Option (None , "--user-email" , help = "Filter by actor email or name (contains, case-insensitive)." ),
483486 status : Optional [str ] = typer .Option (None , "--status" , help = "Filter status-change events by target status (IssueStatusLabel)." ),
484487 history_start : Optional [str ] = typer .Option (None , "--history-start" , help = "History created_at >= this value (YYYY-MM-DD or ISO-8601)." ),
@@ -487,14 +490,30 @@ def vulnerability_timeline(
487490 fmt : str = typer .Option ("table" , "--format" , "-f" , help = "Output format: table, json, csv." ),
488491 output : Optional [str ] = typer .Option (None , "--output" , "-o" , help = "Output file for json/csv." ),
489492):
490- info (f"Listing timeline for vulnerability { issue_id } ..." )
493+ if issue_id is not None and project_id is not None :
494+ error ("Use either --id or --project-id, not both." )
495+ raise typer .Exit (code = 1 )
496+ if issue_id is None and project_id is None :
497+ error ("Provide --id or --project-id (with --company-id)." )
498+ raise typer .Exit (code = 1 )
499+ if project_id is not None and company_id is None :
500+ error ("--company-id is required when using --project-id." )
501+ raise typer .Exit (code = 1 )
502+ if company_id is not None and project_id is None and issue_id is None :
503+ error ("--company-id alone is not enough; use with --project-id or provide --id." )
504+ raise typer .Exit (code = 1 )
505+
506+ if issue_id is not None :
507+ info (f"Listing timeline for vulnerability { issue_id } ..." )
508+ else :
509+ info (f"Listing timeline for vulnerabilities in project { project_id } (company { company_id } )..." )
491510
492511 status_filter = status .strip ().upper () if status else None
493512 email_filter = (user_email or "" ).strip ().lower () or None
494513 history_start_dt = _parse_dt_filter (history_start , end_of_day = False )
495514 history_end_dt = _parse_dt_filter (history_end , end_of_day = True )
496515
497- query = """
516+ issue_timeline_query = """
498517 query IssueTimeline($id: ID!) {
499518 issue(id: $id) {
500519 id
@@ -515,54 +534,114 @@ def vulnerability_timeline(
515534 }
516535 """
517536
518- try :
519- data = graphql_request (query , {"id" : str (issue_id )})
520- issue = data .get ("issue" )
521- if not issue :
522- warning (f"Issue { issue_id } not found." )
523- raise typer .Exit (code = 1 )
524- history_rows = issue .get ("history" ) or []
537+ project_issues_query = """
538+ query IssuesByProject($companyId: ID!, $pagination: PaginationInput!, $filters: IssuesFiltersInput) {
539+ issues(companyId: $companyId, pagination: $pagination, filters: $filters) {
540+ collection {
541+ id
542+ title
543+ status
544+ }
545+ metadata {
546+ currentPage
547+ totalPages
548+ totalCount
549+ }
550+ }
551+ }
552+ """
553+
554+ def _fetch_project_issues (cid : int , pid : int ) -> list [dict ]:
555+ current_page = 1
556+ per_page = 100
557+ out = []
558+ while True :
559+ data = graphql_request (
560+ project_issues_query ,
561+ {
562+ "companyId" : str (cid ),
563+ "pagination" : {"page" : current_page , "perPage" : per_page },
564+ "filters" : {"projectIds" : [pid ]},
565+ },
566+ log_request = True ,
567+ verbose_only = True ,
568+ )
569+ issues = (data .get ("issues" ) or {})
570+ collection = issues .get ("collection" ) or []
571+ metadata = issues .get ("metadata" ) or {}
572+ total_pages = metadata .get ("totalPages" )
573+ out .extend (collection )
574+ if not collection :
575+ break
576+ if total_pages is not None and current_page >= total_pages :
577+ break
578+ if len (collection ) < per_page :
579+ break
580+ current_page += 1
581+ return out
525582
583+ try :
526584 rows = []
527- for h in history_rows :
528- action_type = (h .get ("action" ) or "" ).upper ()
529- actor_email = (h .get ("authorEmail" ) or "" ).strip ()
530- actor_name = actor_email .split ("@" , 1 )[0 ] if actor_email else ""
531- created_at = h .get ("at" ) or ""
532- created_at_dt = _safe_parse_iso (created_at )
533- from_status = (h .get ("previousStatus" ) or "" ).upper ()
534- to_status = (h .get ("status" ) or "" ).upper ()
535- event_status = to_status
536- kind = (h .get ("kind" ) or "" ).lower ()
537- is_status_change = bool (kind == "status" or from_status or to_status )
538-
539- if email_filter :
540- haystack = f"{ actor_email .lower ()} { actor_name .lower ()} " .strip ()
541- if email_filter not in haystack :
542- continue
543- if history_start_dt and (created_at_dt is None or created_at_dt < history_start_dt ):
585+ target_issues = []
586+ if issue_id is not None :
587+ target_issues = [{"id" : str (issue_id )}]
588+ else :
589+ target_issues = _fetch_project_issues (company_id , project_id )
590+ if not target_issues :
591+ warning ("No vulnerabilities found for the given project." )
592+ raise typer .Exit ()
593+
594+ for target in target_issues :
595+ current_issue_id = target .get ("id" )
596+ if not current_issue_id :
544597 continue
545- if history_end_dt and (created_at_dt is None or created_at_dt > history_end_dt ):
598+
599+ data = graphql_request (issue_timeline_query , {"id" : str (current_issue_id )}, log_request = True , verbose_only = True )
600+ issue = data .get ("issue" )
601+ if not issue :
546602 continue
547- if status_filter :
548- if not is_status_change :
603+ history_rows = issue .get ("history" ) or []
604+
605+ for h in history_rows :
606+ action_type = (h .get ("action" ) or "" ).upper ()
607+ actor_email = (h .get ("authorEmail" ) or "" ).strip ()
608+ actor_name = actor_email .split ("@" , 1 )[0 ] if actor_email else ""
609+ created_at = h .get ("at" ) or ""
610+ created_at_dt = _safe_parse_iso (created_at )
611+ from_status = (h .get ("previousStatus" ) or "" ).upper ()
612+ to_status = (h .get ("status" ) or "" ).upper ()
613+ event_status = to_status
614+ kind = (h .get ("kind" ) or "" ).lower ()
615+ is_status_change = bool (kind == "status" or from_status or to_status )
616+
617+ if email_filter :
618+ haystack = f"{ actor_email .lower ()} { actor_name .lower ()} " .strip ()
619+ if email_filter not in haystack :
620+ continue
621+ if history_start_dt and (created_at_dt is None or created_at_dt < history_start_dt ):
549622 continue
550- if ( to_status or event_status ) != status_filter :
623+ if history_end_dt and ( created_at_dt is None or created_at_dt > history_end_dt ) :
551624 continue
625+ if status_filter :
626+ if not is_status_change :
627+ continue
628+ if (to_status or event_status ) != status_filter :
629+ continue
552630
553- rows .append ({
554- "issueId" : issue .get ("id" ) or issue_id ,
555- "issueTitle" : issue .get ("title" ) or "" ,
556- "currentIssueStatus" : issue .get ("status" ) or "" ,
557- "eventId" : h .get ("eventId" ) or "" ,
558- "createdAt" : created_at ,
559- "actorName" : actor_name ,
560- "actorEmail" : actor_email ,
561- "actionType" : action_type ,
562- "fromStatus" : from_status ,
563- "toStatus" : to_status or event_status ,
564- "statusChange" : "true" if is_status_change else "false" ,
565- })
631+ rows .append ({
632+ "projectId" : str (project_id ) if project_id is not None else "" ,
633+ "issueId" : issue .get ("id" ) or current_issue_id ,
634+ "issueTitle" : issue .get ("title" ) or target .get ("title" ) or "" ,
635+ "currentIssueStatus" : issue .get ("status" ) or target .get ("status" ) or "" ,
636+ "eventId" : h .get ("eventId" ) or "" ,
637+ "createdAt" : created_at ,
638+ "actorName" : actor_name ,
639+ "actorEmail" : actor_email ,
640+ "actionType" : action_type ,
641+ "fromStatus" : from_status ,
642+ "toStatus" : to_status or event_status ,
643+ "statusChange" : "true" if is_status_change else "false" ,
644+ })
566645
567646 if not rows :
568647 warning ("No timeline events found for the given filters." )
@@ -581,6 +660,7 @@ def vulnerability_timeline(
581660 )
582661 latest = status_rows [- 1 ]
583662 latest = {
663+ "projectId" : latest .get ("projectId" ),
584664 "issueId" : latest .get ("issueId" ),
585665 "issueTitle" : latest .get ("issueTitle" ),
586666 "currentIssueStatus" : latest .get ("currentIssueStatus" ),
@@ -591,17 +671,80 @@ def vulnerability_timeline(
591671 "toStatus" : latest .get ("toStatus" ),
592672 "actionType" : latest .get ("actionType" ),
593673 }
594- export_data ([latest ], fmt = fmt , output = output , title = f"Vulnerability { issue_id } - Last Status Change" )
674+ if project_id is not None :
675+ grouped_latest = {}
676+ for r in status_rows :
677+ key = str (r .get ("issueId" ) or "" )
678+ curr = grouped_latest .get (key )
679+ if curr is None :
680+ grouped_latest [key ] = r
681+ continue
682+ curr_dt = _safe_parse_iso (curr .get ("createdAt" ) or "" ) or datetime .min .replace (tzinfo = timezone .utc )
683+ r_dt = _safe_parse_iso (r .get ("createdAt" ) or "" ) or datetime .min .replace (tzinfo = timezone .utc )
684+ if r_dt > curr_dt or (r_dt == curr_dt and str (r .get ("eventId" ) or "" ) > str (curr .get ("eventId" ) or "" )):
685+ grouped_latest [key ] = r
686+ latest_rows = []
687+ for r in grouped_latest .values ():
688+ latest_rows .append ({
689+ "projectId" : r .get ("projectId" ),
690+ "issueId" : r .get ("issueId" ),
691+ "issueTitle" : r .get ("issueTitle" ),
692+ "currentIssueStatus" : r .get ("currentIssueStatus" ),
693+ "lastChangedAt" : r .get ("createdAt" ),
694+ "lastChangedBy" : r .get ("actorName" ),
695+ "lastChangedByEmail" : r .get ("actorEmail" ),
696+ "fromStatus" : r .get ("fromStatus" ),
697+ "toStatus" : r .get ("toStatus" ),
698+ "actionType" : r .get ("actionType" ),
699+ })
700+ latest_rows .sort (
701+ key = lambda r : (
702+ _safe_parse_iso (r .get ("lastChangedAt" ) or "" ) or datetime .min .replace (tzinfo = timezone .utc ),
703+ str (r .get ("issueId" ) or "" ),
704+ )
705+ )
706+ export_data (
707+ latest_rows ,
708+ schema = timeline_last_schema ,
709+ fmt = fmt ,
710+ output = output ,
711+ title = f"Project { project_id } - Last Status Change Per Vulnerability" ,
712+ )
713+ summary (f"{ len (latest_rows )} vulnerability(ies) with last status-change listed." )
714+ return
715+
716+ export_data (
717+ [latest ],
718+ schema = timeline_last_schema ,
719+ fmt = fmt ,
720+ output = output ,
721+ title = f"Vulnerability { issue_id } - Last Status Change" ,
722+ )
595723 summary ("1 last status-change event listed." )
596724 return
597725
598- export_data (rows , fmt = fmt , output = output , title = f"Vulnerability { issue_id } - Timeline" )
726+ if project_id is not None :
727+ export_data (
728+ rows ,
729+ schema = timeline_schema ,
730+ fmt = fmt ,
731+ output = output ,
732+ title = f"Project { project_id } - Vulnerabilities Timeline" ,
733+ )
734+ else :
735+ export_data (
736+ rows ,
737+ schema = timeline_schema ,
738+ fmt = fmt ,
739+ output = output ,
740+ title = f"Vulnerability { issue_id } - Timeline" ,
741+ )
599742 summary (f"{ len (rows )} timeline event(s) listed." )
600743
601744 except typer .Exit :
602745 raise
603746 except Exception as exc :
604- if "RECORD_NOT_FOUND" in str (exc ):
747+ if "RECORD_NOT_FOUND" in str (exc ) and issue_id is not None :
605748 error (f"Issue { issue_id } not found. Use the vulnerability ID (not project ID)." )
606749 raise typer .Exit (code = 1 )
607750 error (f"Error listing vulnerability timeline: { exc } " )
0 commit comments