From 3a3ae780ace1ef2093da93928c1b33ce959a2cbf Mon Sep 17 00:00:00 2001 From: Ondrej Dockal Date: Thu, 16 Jul 2026 14:17:03 +0200 Subject: [PATCH 1/2] chore: add python qe-on-duty reporter Signed-off-by: Ondrej Dockal --- .gitignore | 1 + qe-on-duty-reporter/.gitignore | 13 + qe-on-duty-reporter/README.md | 276 +++++++++++++++++ qe-on-duty-reporter/config.yaml | 59 ++++ qe-on-duty-reporter/main.py | 289 ++++++++++++++++++ qe-on-duty-reporter/qe_on_duty/__init__.py | 0 .../qe_on_duty/collectors/__init__.py | 0 .../qe_on_duty/collectors/cve_collector.py | 108 +++++++ .../qe_on_duty/collectors/issue_collector.py | 106 +++++++ .../qe_on_duty/collectors/pr_collector.py | 117 +++++++ .../collectors/workflow_collector.py | 102 +++++++ qe-on-duty-reporter/qe_on_duty/config.py | 151 +++++++++ qe-on-duty-reporter/qe_on_duty/gh_client.py | 202 ++++++++++++ .../qe_on_duty/models/__init__.py | 0 .../qe_on_duty/models/snapshot.py | 119 ++++++++ .../qe_on_duty/reporters/__init__.py | 0 .../qe_on_duty/reporters/daily_reporter.py | 283 +++++++++++++++++ .../qe_on_duty/reporters/weekly_reporter.py | 190 ++++++++++++ .../qe_on_duty/utils/__init__.py | 0 .../qe_on_duty/utils/repository_parser.py | 81 +++++ qe-on-duty-reporter/requirements.md | 33 ++ qe-on-duty-reporter/requirements.txt | 2 + qe-on-duty-reporter/tests/__init__.py | 0 23 files changed, 2132 insertions(+) create mode 100644 qe-on-duty-reporter/.gitignore create mode 100644 qe-on-duty-reporter/README.md create mode 100644 qe-on-duty-reporter/config.yaml create mode 100755 qe-on-duty-reporter/main.py create mode 100644 qe-on-duty-reporter/qe_on_duty/__init__.py create mode 100644 qe-on-duty-reporter/qe_on_duty/collectors/__init__.py create mode 100644 qe-on-duty-reporter/qe_on_duty/collectors/cve_collector.py create mode 100644 qe-on-duty-reporter/qe_on_duty/collectors/issue_collector.py create mode 100644 qe-on-duty-reporter/qe_on_duty/collectors/pr_collector.py create mode 100644 qe-on-duty-reporter/qe_on_duty/collectors/workflow_collector.py create mode 100644 qe-on-duty-reporter/qe_on_duty/config.py create mode 100644 qe-on-duty-reporter/qe_on_duty/gh_client.py create mode 100644 qe-on-duty-reporter/qe_on_duty/models/__init__.py create mode 100644 qe-on-duty-reporter/qe_on_duty/models/snapshot.py create mode 100644 qe-on-duty-reporter/qe_on_duty/reporters/__init__.py create mode 100644 qe-on-duty-reporter/qe_on_duty/reporters/daily_reporter.py create mode 100644 qe-on-duty-reporter/qe_on_duty/reporters/weekly_reporter.py create mode 100644 qe-on-duty-reporter/qe_on_duty/utils/__init__.py create mode 100644 qe-on-duty-reporter/qe_on_duty/utils/repository_parser.py create mode 100644 qe-on-duty-reporter/requirements.md create mode 100644 qe-on-duty-reporter/requirements.txt create mode 100644 qe-on-duty-reporter/tests/__init__.py diff --git a/.gitignore b/.gitignore index 01344990..dbfc8578 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ oci/bin out +output \ No newline at end of file diff --git a/qe-on-duty-reporter/.gitignore b/qe-on-duty-reporter/.gitignore new file mode 100644 index 00000000..ab72bd01 --- /dev/null +++ b/qe-on-duty-reporter/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.egg-info/ +.eggs/ +dist/ +build/ +.venv/ +venv/ +ENV/ +.env +output/ diff --git a/qe-on-duty-reporter/README.md b/qe-on-duty-reporter/README.md new file mode 100644 index 00000000..c69f0397 --- /dev/null +++ b/qe-on-duty-reporter/README.md @@ -0,0 +1,276 @@ +# QE On-Duty Automation + +Automated data collection and reporting for Podman Desktop QE on-duty rotation responsibilities. + +## Overview + +This script automates the monitoring of: +- Pull requests requiring QE review (labels: `qe/testing-required`, `qe/review`) +- CI/CD workflow test results across 28+ repositories +- CVE/Dependabot security alerts +- Open bug reports + +It generates daily snapshots (JSON) and human-readable reports (Markdown) to track QE activities throughout the week. + +## Prerequisites + +### Required Software + +1. **Python 3.8+** + ```bash + python3 --version + ``` + +2. **GitHub CLI (`gh`)** + ```bash + # Install gh CLI + # https://cli.github.com/ + + # Verify installation + gh --version + ``` + +3. **GitHub Authentication** + ```bash + # Authenticate with GitHub + gh auth login + + # Verify authentication + gh auth status + ``` + +### Required Permissions + +Your GitHub account needs: +- Read access to all monitored repositories +- `security_events` scope for Dependabot alerts (some repos may not grant this) + +## Installation + +1. **Clone or navigate to the repository** + ```bash + cd /home/odockal/git/github-apps/qe-on-duty + ``` + +2. **Install Python dependencies** + ```bash + pip install -r requirements.txt + ``` + +3. **Verify configuration** + Edit `config.yaml` if needed to customize: + - Repository lists + - Filter labels + - Output directory + - Time ranges + +## Usage + +### Daily Report (Default) + +Run once or multiple times per day to capture current state: + +```bash +# Run with current date/time +python main.py + +# Run with specific date +python main.py --date 2026-04-21 + +# Run with specific date and time +python main.py --date 2026-04-21 --time 1430 +``` + +**Output**: +- JSON snapshot: `output/snapshots/YYYY-MM-DD/HHMM.json` +- Markdown report: `output/reports/daily/YYYY-MM-DD/HHMM.md` +- Symlink: `output/snapshots/YYYY-MM-DD/latest.json` → points to most recent + +### Weekly Report + +Aggregate daily snapshots into a weekly summary: + +```bash +# Generate weekly report for current week +python main.py --weekly + +# Generate weekly report for specific week ending on date +python main.py --weekly --date 2026-04-25 +``` + +**Output**: +- Markdown report: `output/reports/weekly/YYYY-week-NN.md` + +### Custom Configuration + +```bash +# Use custom config file +python main.py --config /path/to/config.yaml + +# Use custom output directory +python main.py --output-dir /tmp/qe-reports +``` + +## Output Structure + +``` +output/ +├── snapshots/ +│ └── 2026-04-21/ +│ ├── 0930.json # 9:30 AM run +│ ├── 1430.json # 2:30 PM run +│ └── latest.json # Symlink to 1430.json +└── reports/ + ├── daily/ + │ └── 2026-04-21/ + │ ├── 0930.md + │ └── 1430.md + └── weekly/ + └── 2026-week-16.md +``` + +## Configuration + +Edit `config.yaml` to customize behavior: + +```yaml +github: + cli_path: "gh" # Path to gh CLI + max_retries: 3 # Retry failed commands + +repositories: + core: + - "podman-desktop/podman-desktop" + - "podman-desktop/e2e" + domains_json: "/path/to/domains.json" # Extension repos + +pr_filters: + labels: + - "qe/testing-required" + - "qe/review" + assignee_teams: + - "qe-reviewers" + +workflow_filters: + max_age_days: 7 # Only workflows from last 7 days + +cve_filters: + states: ["open"] + severities: ["critical", "high", "medium", "low"] + +issue_filters: + labels: ["kind/bug"] + states: ["open"] + max_age_days: 30 +``` + +## Troubleshooting + +### Authentication Issues + +```bash +# Check gh auth status +gh auth status + +# Re-authenticate if needed +gh auth login + +# Use token if needed +export GITHUB_TOKEN="ghp_your_token_here" +gh auth status +``` + +### Rate Limiting + +The `gh` CLI handles rate limiting automatically. If you hit limits: +- Wait for the reset time (shown in error message) +- Script will pause and retry automatically + +### Permission Errors + +Some repositories may not grant access to Dependabot alerts (403/404 errors). This is expected and the script will continue with other repositories. + +### Missing Data + +If no data appears for certain repositories: +- Verify repository names in `config.yaml` +- Check that repositories exist and you have access +- Verify labels/filters match your repository structure + +## Development + +### Project Structure + +``` +qe-on-duty/ +├── qe_on_duty/ +│ ├── gh_client.py # GitHub CLI wrapper +│ ├── config.py # Configuration management +│ ├── collectors/ # Data collectors +│ │ ├── pr_collector.py +│ │ ├── workflow_collector.py +│ │ ├── cve_collector.py +│ │ └── issue_collector.py +│ ├── models/ +│ │ └── snapshot.py # Data models +│ ├── reporters/ +│ │ ├── daily_reporter.py +│ │ └── weekly_reporter.py +│ └── utils/ +│ └── repository_parser.py +├── main.py # CLI entry point +├── config.yaml +└── requirements.txt +``` + +### Running Tests + +```bash +# Run unit tests (when implemented) +pytest tests/ + +# Run with coverage +pytest --cov=qe_on_duty tests/ +``` + +### Adding New Collectors + +1. Create new collector in `qe_on_duty/collectors/` +2. Inherit from base pattern (see existing collectors) +3. Add data model in `qe_on_duty/models/snapshot.py` +4. Update `main.py` to include new collector +5. Update reporters to display new data + +## Automation + +### Cron Job + +```bash +# Edit crontab +crontab -e + +# Run daily at 9 AM +0 9 * * * cd /path/to/qe-on-duty && /usr/bin/python3 main.py + +# Run weekly report on Fridays at 5 PM +0 17 * * 5 cd /path/to/qe-on-duty && /usr/bin/python3 main.py --weekly +``` + +### GitHub Actions + +See `.github/workflows/qe-on-duty-daily.yaml` (example not included in initial implementation) + +## License + +Internal Red Hat tool for Podman Desktop QE team. + +## Support + +For issues or questions: +- File an issue in the repository +- Contact the QE team on Slack + +--- + +**Generated by**: Podman Desktop QE Team +**Maintained by**: QE On-Duty rotation diff --git a/qe-on-duty-reporter/config.yaml b/qe-on-duty-reporter/config.yaml new file mode 100644 index 00000000..8bf161f4 --- /dev/null +++ b/qe-on-duty-reporter/config.yaml @@ -0,0 +1,59 @@ +github: + cli_path: "gh" # Path to gh CLI binary (default: "gh" in PATH) + max_retries: 3 + +repositories: + core: + - "podman-desktop/podman-desktop" + - "podman-desktop/e2e" + custom: [] + # Add extra repositories not covered by core or domains.json, eg: + # - "owner/repo" + domains_json: "/home/odockal/git/podman-desktop-internal/domains.json" + domains_user_name: "Ondrej" # Your name as it appears in domains.json qe_owners + +output: + base_dir: "output" + +pr_filters: + labels: + - "qe/testing-required" + - "qe/review" + assignee_teams: + - "qe-reviewers" + qe_reviewers: + - "odockal" + # Add more QE team GitHub usernames here + +workflow_filters: + overnight_start_hour: 18 # Look for failures from yesterday at this hour until now + default_name_pattern: "e2e" # Default: match workflows containing this (case-insensitive) + exclude_workflows: + - "pr-check" # Workflow names to always exclude (case-insensitive substring match) + repo_workflows: + # Extra workflow names to include per repo, on top of the default_name_pattern. + # Exclusions still apply. + "podman-desktop/podman-desktop": + - "e2e" + - "daily-testing-build" + - "managed-configuration" + - "next-build" + "podman-desktop/e2e": + - "e2e" + - "stress" + +cve_filters: + states: + - "open" + severities: + - "critical" + - "high" + - "medium" + - "low" + +issue_filters: + labels: + - "kind/bug" + states: + - "open" + max_age_days: 30 diff --git a/qe-on-duty-reporter/main.py b/qe-on-duty-reporter/main.py new file mode 100755 index 00000000..346dcf27 --- /dev/null +++ b/qe-on-duty-reporter/main.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""QE On-Duty automation script - main entry point.""" + +import argparse +import os +import sys +from datetime import datetime +from pathlib import Path + +from qe_on_duty.config import Config +from qe_on_duty.gh_client import GHClient +from qe_on_duty.collectors.pr_collector import PRCollector +from qe_on_duty.collectors.workflow_collector import WorkflowCollector +from qe_on_duty.collectors.cve_collector import CVECollector +from qe_on_duty.collectors.issue_collector import IssueCollector +from qe_on_duty.models.snapshot import DailySnapshot, SummaryData +from qe_on_duty.reporters.daily_reporter import DailyReporter +from qe_on_duty.reporters.weekly_reporter import WeeklyReporter + + +def calculate_summary(prs, workflows, cves, issues) -> SummaryData: + """ + Calculate summary statistics from collected data. + + Args: + prs: List of PRData objects + workflows: List of WorkflowData objects + cves: List of CVEData objects + issues: List of IssueData objects + + Returns: + SummaryData object + """ + overnight = [w for w in workflows if not w.is_fallback] + return SummaryData( + total_prs_needing_qe=len(prs), + total_workflow_runs=len(overnight), + failed_workflow_runs=len([w for w in overnight if w.conclusion == 'failure']), + critical_cves=len([c for c in cves if c.severity == 'critical']), + high_cves=len([c for c in cves if c.severity == 'high']), + total_open_bugs=len(issues) + ) + + +def load_daily_snapshots(output_dir: str, start_date: datetime, end_date: datetime) -> list: + """ + Load daily snapshots for a date range (latest snapshot per day). + + Args: + output_dir: Base output directory + start_date: Start date + end_date: End date + + Returns: + List of DailySnapshot objects + """ + from datetime import timedelta + + snapshots = [] + current = start_date + + while current <= end_date: + date_str = current.strftime('%Y-%m-%d') + day_dir = os.path.join(output_dir, 'snapshots', date_str) + + if os.path.exists(day_dir): + # Check for latest.json symlink + latest_link = os.path.join(day_dir, 'latest.json') + if os.path.exists(latest_link): + try: + snapshot = DailySnapshot.load(latest_link) + snapshots.append(snapshot) + except Exception as e: + print(f"⚠️ Failed to load snapshot for {date_str}: {e}") + else: + # Find most recent snapshot in the directory + snapshot_files = sorted([f for f in os.listdir(day_dir) if f.endswith('.json') and f != 'latest.json']) + if snapshot_files: + latest_file = os.path.join(day_dir, snapshot_files[-1]) + try: + snapshot = DailySnapshot.load(latest_file) + snapshots.append(snapshot) + except Exception as e: + print(f"⚠️ Failed to load snapshot for {date_str}: {e}") + + current = current + timedelta(days=1) + + return snapshots + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Podman Desktop QE On-Duty Automation", + formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + '--config', + default='config.yaml', + help='Path to configuration file (default: config.yaml)' + ) + parser.add_argument( + '--daily', + action='store_true', + default=True, + help='Generate daily report (default)' + ) + parser.add_argument( + '--weekly', + action='store_true', + help='Generate weekly summary report' + ) + parser.add_argument( + '--output-dir', + help='Override output directory' + ) + parser.add_argument( + '--date', + help='Date for report (YYYY-MM-DD), defaults to today' + ) + parser.add_argument( + '--time', + help='Time for report (HHMM), defaults to current time' + ) + + args = parser.parse_args() + + # Load configuration + try: + config = Config(args.config) + except Exception as e: + print(f"❌ Failed to load configuration: {e}") + sys.exit(1) + + if args.output_dir: + config.output_base_dir = args.output_dir + config.snapshots_dir = os.path.join(args.output_dir, 'snapshots') + config.reports_dir = os.path.join(args.output_dir, 'reports') + + # Parse date and time + now = datetime.now() + report_date = args.date if args.date else now.strftime('%Y-%m-%d') + report_time = args.time if args.time else now.strftime('%H%M') + timestamp = f"{report_date}T{report_time[:2]}:{report_time[2:]}:00Z" + + # Initialize GitHub CLI client + try: + gh_client = GHClient(config.gh_cli_path, config.max_retries) + except Exception as e: + print(f"❌ {e}") + sys.exit(1) + + # Get current authenticated user + try: + current_user = gh_client.get_current_user() + print(f"👤 Authenticated as: {current_user}") + except Exception as e: + print(f"⚠️ Could not detect current user: {e}") + current_user = "" + + # Get all repositories with source information + categorized = config.get_categorized_repositories() + all_repos = config.get_all_repositories() + + print(f"\n📊 Repositories included in this run ({len(all_repos)} total):") + if categorized['core']: + print(f"\n Core ({len(categorized['core'])}):") + for repo in categorized['core']: + print(f" - {repo}") + if categorized['domains']: + print(f"\n Domains.json — owned by {config.domains_user_name} ({len(categorized['domains'])}):") + for repo in categorized['domains']: + print(f" - {repo}") + if categorized['custom']: + print(f"\n Custom ({len(categorized['custom'])}):") + for repo in categorized['custom']: + print(f" - {repo}") + print() + + if args.weekly: + # Generate weekly report from existing snapshots + from datetime import timedelta + end = datetime.strptime(report_date, '%Y-%m-%d') + start = end - timedelta(days=6) # Last 7 days including today + + print(f"📅 Loading snapshots from {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}...") + snapshots = load_daily_snapshots(config.output_base_dir, start, end) + + if not snapshots: + print("❌ No snapshots found for the specified period") + sys.exit(1) + + print(f"📄 Generating weekly report from {len(snapshots)} snapshots...") + weekly_reporter = WeeklyReporter(snapshots, config) + report = weekly_reporter.generate() + + # Determine week number + week_num = end.isocalendar()[1] + report_path = os.path.join(config.reports_dir, 'weekly', f"{end.year}-week-{week_num:02d}.md") + + os.makedirs(os.path.dirname(report_path), exist_ok=True) + with open(report_path, 'w') as f: + f.write(report) + + print(f"📄 Weekly report saved: {report_path}") + sys.exit(0) + + # Daily report - collect fresh data + # Initialize collectors + pr_collector = PRCollector(gh_client, config, current_user=current_user) + workflow_collector = WorkflowCollector(gh_client, config) + cve_collector = CVECollector(gh_client, config) + issue_collector = IssueCollector(gh_client, config) + + # Collect data + print("🔍 Collecting PRs...") + prs = pr_collector.collect(all_repos) + print(f" Found {len(prs)} PRs needing QE review") + + cutoff = workflow_collector._get_overnight_cutoff() + print(f"🔍 Collecting workflow runs since {cutoff.strftime('%Y-%m-%d %H:%M')}...") + workflows = workflow_collector.collect(all_repos) + overnight = [w for w in workflows if not w.is_fallback] + fallback = [w for w in workflows if w.is_fallback] + overnight_failed = len([w for w in overnight if w.conclusion == 'failure']) + print(f" Overnight: {overnight_failed} failed / {len(overnight)} total") + if fallback: + print(f" Fallback (latest runs, no overnight activity): {len(fallback)} repos") + + print("🔍 Collecting CVE alerts...") + cves = cve_collector.collect(all_repos) + print(f" Found {len(cves)} CVE alerts") + + print("🔍 Collecting issues...") + issues = issue_collector.collect(all_repos) + print(f" Found {len(issues)} open bugs") + + # Create snapshot + snapshot = DailySnapshot( + date=report_date, + time=report_time[:2] + ":" + report_time[2:], + timestamp=timestamp, + prs=prs, + workflows=workflows, + cves=cves, + issues=issues, + summary=calculate_summary(prs, workflows, cves, issues) + ) + + # Create output directories + day_snapshot_dir = os.path.join(config.snapshots_dir, report_date) + day_report_dir = os.path.join(config.reports_dir, 'daily', report_date) + os.makedirs(day_snapshot_dir, exist_ok=True) + os.makedirs(day_report_dir, exist_ok=True) + + # Save snapshot + snapshot_path = os.path.join(day_snapshot_dir, f"{report_time}.json") + snapshot.save(snapshot_path) + print(f"💾 Snapshot saved: {snapshot_path}") + + # Create/update latest.json symlink + latest_link = os.path.join(day_snapshot_dir, 'latest.json') + if os.path.exists(latest_link): + os.remove(latest_link) + os.symlink(f"{report_time}.json", latest_link) + print(f"🔗 Latest symlink updated: {latest_link}") + + # Generate daily report + daily_reporter = DailyReporter(snapshot, config) + report_path = os.path.join(day_report_dir, f"{report_time}.md") + daily_reporter.save(report_path) + print(f"📄 Report saved: {report_path}") + + # Print summary to stdout + print("\n" + "=" * 60) + print(f"✅ QE On-Duty Report - {report_date} {report_time[:2]}:{report_time[2:]}") + print("=" * 60) + qe_reviewers = sorted(pr_collector.qe_reviewers) + print(f"QE reviewers tracked: {', '.join(qe_reviewers)}") + print(f"PRs needing QE review: {snapshot.summary.total_prs_needing_qe}") + print(f"Failed workflows: {snapshot.summary.failed_workflow_runs}/{snapshot.summary.total_workflow_runs}") + print(f"Critical CVEs: {snapshot.summary.critical_cves}") + print(f"High CVEs: {snapshot.summary.high_cves}") + print(f"Open bugs: {snapshot.summary.total_open_bugs}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/qe-on-duty-reporter/qe_on_duty/__init__.py b/qe-on-duty-reporter/qe_on_duty/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qe-on-duty-reporter/qe_on_duty/collectors/__init__.py b/qe-on-duty-reporter/qe_on_duty/collectors/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qe-on-duty-reporter/qe_on_duty/collectors/cve_collector.py b/qe-on-duty-reporter/qe_on_duty/collectors/cve_collector.py new file mode 100644 index 00000000..ecfcf446 --- /dev/null +++ b/qe-on-duty-reporter/qe_on_duty/collectors/cve_collector.py @@ -0,0 +1,108 @@ +"""Collector for CVE and Dependabot alerts.""" + +from typing import List +from qe_on_duty.gh_client import GHClient +from qe_on_duty.config import Config +from qe_on_duty.models.snapshot import CVEData + + +class CVECollector: + """Collect CVE and Dependabot alerts.""" + + def __init__(self, gh_client: GHClient, config: Config): + """ + Initialize CVE collector. + + Args: + gh_client: GitHub CLI client + config: Configuration object + """ + self.gh_client = gh_client + self.config = config + self.allowed_states = config.get_cve_states() + self.allowed_severities = config.get_cve_severities() + + def collect(self, repositories: List[str]) -> List[CVEData]: + """ + Collect CVE alerts from all repositories. + + Args: + repositories: List of repository names in owner/repo format + + Returns: + List of CVEData objects + """ + all_cves = [] + for repo in repositories: + try: + cves = self._collect_from_repo(repo) + all_cves.extend(cves) + except Exception as e: + # Don't print warning for common "no access" errors + if "forbidden" not in str(e).lower() and "404" not in str(e).lower(): + print(f"⚠️ Failed to collect CVEs from {repo}: {e}") + + return all_cves + + def _collect_from_repo(self, repo: str) -> List[CVEData]: + """ + Collect CVE alerts from a single repository. + + Args: + repo: Repository name in owner/repo format + + Returns: + List of CVEData objects + """ + # Use gh api to get Dependabot alerts + endpoint = f"/repos/{repo}/dependabot/alerts" + alerts = self.gh_client.api(endpoint) + + # gh api returns empty list if no access or no alerts + if not alerts: + return [] + + # Filter and parse alerts + filtered_cves = [] + for alert in alerts: + # Filter by state + if alert.get('state') not in self.allowed_states: + continue + + # Filter by severity + severity = alert.get('security_advisory', {}).get('severity', 'unknown') + if severity not in self.allowed_severities: + continue + + filtered_cves.append(self._parse_cve(alert, repo)) + + return filtered_cves + + def _parse_cve(self, alert: dict, repo: str) -> CVEData: + """ + Parse CVE alert data from gh API output to CVEData model. + + Args: + alert: CVE alert data dictionary from gh API + repo: Repository name + + Returns: + CVEData object + """ + security_advisory = alert.get('security_advisory', {}) + vulnerability = alert.get('security_vulnerability', {}) + package = vulnerability.get('package', {}) + + return CVEData( + repository=repo, + alert_number=alert.get('number', 0), + state=alert.get('state', 'unknown'), + severity=security_advisory.get('severity', 'unknown'), + cve_id=security_advisory.get('cve_id', security_advisory.get('ghsa_id', 'unknown')), + package_name=package.get('name', 'unknown'), + vulnerable_version=vulnerability.get('vulnerable_version_range', 'unknown'), + patched_version=vulnerability.get('first_patched_version', {}).get('identifier', 'none'), + url=alert.get('html_url', ''), + created_at=alert.get('created_at', ''), + updated_at=alert.get('updated_at', '') + ) diff --git a/qe-on-duty-reporter/qe_on_duty/collectors/issue_collector.py b/qe-on-duty-reporter/qe_on_duty/collectors/issue_collector.py new file mode 100644 index 00000000..266cfc0d --- /dev/null +++ b/qe-on-duty-reporter/qe_on_duty/collectors/issue_collector.py @@ -0,0 +1,106 @@ +"""Collector for issues and bug reports.""" + +from typing import List +from datetime import datetime, timedelta +from dateutil import parser as date_parser +from qe_on_duty.gh_client import GHClient +from qe_on_duty.config import Config +from qe_on_duty.models.snapshot import IssueData + + +class IssueCollector: + """Collect issues and bug reports.""" + + def __init__(self, gh_client: GHClient, config: Config): + """ + Initialize issue collector. + + Args: + gh_client: GitHub CLI client + config: Configuration object + """ + self.gh_client = gh_client + self.config = config + self.labels = config.get_issue_labels() + self.states = config.get_issue_states() + self.max_age_days = config.get_issue_max_age_days() + + def collect(self, repositories: List[str]) -> List[IssueData]: + """ + Collect issues from all repositories. + + Args: + repositories: List of repository names in owner/repo format + + Returns: + List of IssueData objects + """ + all_issues = [] + for repo in repositories: + try: + issues = self._collect_from_repo(repo) + all_issues.extend(issues) + except Exception as e: + print(f"⚠️ Failed to collect issues from {repo}: {e}") + + return all_issues + + def _collect_from_repo(self, repo: str) -> List[IssueData]: + """ + Collect issues from a single repository. + + Args: + repo: Repository name in owner/repo format + + Returns: + List of IssueData objects + """ + # Get issues with specified labels and state + issues = self.gh_client.issue_list( + repo, + labels=self.labels, + state=self.states[0] if self.states else "open", + limit=100 + ) + + # Filter by age if needed + if self.max_age_days > 0: + cutoff_date = datetime.now() - timedelta(days=self.max_age_days) + filtered_issues = [] + for issue in issues: + # Filter out pull requests (GitHub API returns PRs as issues) + if 'pull_request' in issue: + continue + + created_at = date_parser.parse(issue['createdAt']) + if created_at.replace(tzinfo=None) >= cutoff_date: + filtered_issues.append(self._parse_issue(issue, repo)) + return filtered_issues + else: + # Return all issues (still filter out PRs) + return [self._parse_issue(issue, repo) for issue in issues if 'pull_request' not in issue] + + def _parse_issue(self, issue: dict, repo: str) -> IssueData: + """ + Parse issue data from gh CLI output to IssueData model. + + Args: + issue: Issue data dictionary from gh CLI + repo: Repository name + + Returns: + IssueData object + """ + return IssueData( + repository=repo, + number=issue['number'], + title=issue['title'], + url=issue['url'], + author=issue['author']['login'] if issue.get('author') else 'unknown', + labels=[label['name'] for label in issue.get('labels', [])], + assignees=[assignee['login'] for assignee in issue.get('assignees', [])], + state=issue['state'], + created_at=issue['createdAt'], + updated_at=issue['updatedAt'], + comments_count=issue.get('comments', 0) + ) diff --git a/qe-on-duty-reporter/qe_on_duty/collectors/pr_collector.py b/qe-on-duty-reporter/qe_on_duty/collectors/pr_collector.py new file mode 100644 index 00000000..133a693e --- /dev/null +++ b/qe-on-duty-reporter/qe_on_duty/collectors/pr_collector.py @@ -0,0 +1,117 @@ +"""Collector for pull requests requiring QE review.""" + +from typing import List +from datetime import datetime +from qe_on_duty.gh_client import GHClient +from qe_on_duty.config import Config +from qe_on_duty.models.snapshot import PRData + + +class PRCollector: + """Collect pull requests requiring QE review.""" + + def __init__(self, gh_client: GHClient, config: Config, current_user: str = ""): + """ + Initialize PR collector. + + Args: + gh_client: GitHub CLI client + config: Configuration object + current_user: GitHub username of the person running the script + """ + self.gh_client = gh_client + self.config = config + self.qe_labels = config.get_pr_labels() + self.qe_teams = config.get_pr_assignee_teams() + self.qe_reviewers = set(config.get_qe_reviewers()) + if current_user: + self.qe_reviewers.add(current_user) + self.current_user = current_user + + def collect(self, repositories: List[str]) -> List[PRData]: + """ + Collect PRs needing QE review from all repositories. + + Args: + repositories: List of repository names in owner/repo format + + Returns: + List of PRData objects + """ + all_prs = [] + for repo in repositories: + try: + prs = self._collect_from_repo(repo) + all_prs.extend(prs) + except Exception as e: + print(f"⚠️ Failed to collect PRs from {repo}: {e}") + + return all_prs + + def _collect_from_repo(self, repo: str) -> List[PRData]: + """ + Collect PRs from a single repository. + + Args: + repo: Repository name in owner/repo format + + Returns: + List of PRData objects + """ + # Get all open PRs + prs = self.gh_client.pr_list(repo, state="open") + + # Filter PRs matching QE criteria + qe_prs = [] + for pr in prs: + if self._matches_qe_criteria(pr): + qe_prs.append(self._parse_pr(pr, repo)) + + return qe_prs + + def _matches_qe_criteria(self, pr: dict) -> bool: + """ + Check if PR matches QE review criteria. + + A PR matches if it has a QE label, a QE team assignee, + or is assigned to any known QE reviewer or the current user. + + Args: + pr: PR data dictionary from gh CLI + + Returns: + True if PR needs QE review + """ + pr_labels = [label['name'] for label in pr.get('labels', [])] + has_qe_label = any(label in self.qe_labels for label in pr_labels) + + assignees = [assignee['login'] for assignee in pr.get('assignees', [])] + has_qe_team = any(team in assignees for team in self.qe_teams) + has_qe_reviewer = any(a in self.qe_reviewers for a in assignees) + + return has_qe_label or has_qe_team or has_qe_reviewer + + def _parse_pr(self, pr: dict, repo: str) -> PRData: + """ + Parse PR data from gh CLI output to PRData model. + + Args: + pr: PR data dictionary from gh CLI + repo: Repository name + + Returns: + PRData object + """ + return PRData( + repository=repo, + number=pr['number'], + title=pr['title'], + url=pr['url'], + author=pr['author']['login'] if pr.get('author') else 'unknown', + labels=[label['name'] for label in pr.get('labels', [])], + assignees=[assignee['login'] for assignee in pr.get('assignees', [])], + created_at=pr['createdAt'], + updated_at=pr['updatedAt'], + state=pr['state'], + draft=pr.get('isDraft', False) + ) diff --git a/qe-on-duty-reporter/qe_on_duty/collectors/workflow_collector.py b/qe-on-duty-reporter/qe_on_duty/collectors/workflow_collector.py new file mode 100644 index 00000000..8dfe3cd9 --- /dev/null +++ b/qe-on-duty-reporter/qe_on_duty/collectors/workflow_collector.py @@ -0,0 +1,102 @@ +"""Collector for CI/CD workflow runs.""" + +from typing import List, Optional +from datetime import datetime, timedelta +from dateutil import parser as date_parser +from qe_on_duty.gh_client import GHClient +from qe_on_duty.config import Config +from qe_on_duty.models.snapshot import WorkflowData + + +class WorkflowCollector: + """Collect failed CI/CD workflow runs from the overnight window.""" + + def __init__(self, gh_client: GHClient, config: Config): + self.gh_client = gh_client + self.config = config + self.overnight_start_hour = config.get_workflow_overnight_start_hour() + self.default_pattern = config.get_workflow_default_name_pattern().lower() + self.repo_overrides = config.get_workflow_repo_overrides() + self.exclusions = [e.lower() for e in config.get_workflow_exclusions()] + + def collect(self, repositories: List[str]) -> List[WorkflowData]: + all_workflows = [] + for repo in repositories: + try: + workflows = self._collect_from_repo(repo) + all_workflows.extend(workflows) + except Exception as e: + print(f"⚠️ Failed to collect workflows from {repo}: {e}") + + return all_workflows + + def _get_overnight_cutoff(self) -> datetime: + """Yesterday at overnight_start_hour.""" + now = datetime.now() + yesterday = now - timedelta(days=1) + return yesterday.replace(hour=self.overnight_start_hour, minute=0, second=0, microsecond=0) + + def _is_excluded(self, workflow_name: str) -> bool: + """Check if a workflow name matches any exclusion pattern.""" + name_lower = workflow_name.lower() + return any(excl in name_lower for excl in self.exclusions) + + def _matches_workflow_name(self, workflow_name: str, repo: str) -> bool: + """Check if a workflow name matches the default pattern or repo-specific list, and is not excluded.""" + if self._is_excluded(workflow_name): + return False + if self.default_pattern in workflow_name.lower(): + return True + extra = self.repo_overrides.get(repo) + if extra and workflow_name in extra: + return True + return False + + def _collect_from_repo(self, repo: str) -> List[WorkflowData]: + cutoff = self._get_overnight_cutoff() + created_filter = f">={cutoff.strftime('%Y-%m-%dT%H:%M:%S')}" + runs = self.gh_client.run_list(repo, limit=500, created=created_filter) + + results = [] + for run in runs: + wf_name = run.get('workflowName', run.get('name', '')) + if not self._matches_workflow_name(wf_name, repo): + continue + results.append(self._parse_workflow(run, repo)) + + if results: + return results + + return self._collect_latest_from_repo(repo) + + def _collect_latest_from_repo(self, repo: str) -> List[WorkflowData]: + """Fallback: fetch the latest run per matching workflow when no overnight runs exist.""" + runs = self.gh_client.run_list(repo, limit=100) + + latest_per_workflow = {} + for run in runs: + wf_name = run.get('workflowName', run.get('name', '')) + if not self._matches_workflow_name(wf_name, repo): + continue + if wf_name not in latest_per_workflow: + latest_per_workflow[wf_name] = run + + return [self._parse_workflow(run, repo, is_fallback=True) + for run in latest_per_workflow.values()] + + def _parse_workflow(self, run: dict, repo: str, is_fallback: bool = False) -> WorkflowData: + return WorkflowData( + repository=repo, + workflow_name=run.get('workflowName', run.get('name', 'unknown')), + workflow_id=run.get('databaseId', 0), + run_number=run.get('databaseId', 0), + run_id=run.get('databaseId', 0), + status=run.get('status', 'unknown'), + conclusion=run.get('conclusion', 'unknown'), + branch=run.get('headBranch', 'unknown'), + commit_sha=run.get('headSha', ''), + url=run.get('url', ''), + started_at=run.get('createdAt', ''), + completed_at=run.get('updatedAt', ''), + is_fallback=is_fallback, + ) diff --git a/qe-on-duty-reporter/qe_on_duty/config.py b/qe-on-duty-reporter/qe_on_duty/config.py new file mode 100644 index 00000000..ddbcadc8 --- /dev/null +++ b/qe-on-duty-reporter/qe_on_duty/config.py @@ -0,0 +1,151 @@ +"""Configuration management for QE on-duty script.""" + +import yaml +import os +from typing import List, Dict, Any +from qe_on_duty.utils.repository_parser import RepositoryParser, Repository + + +class Config: + """Configuration loader and manager.""" + + def __init__(self, config_path: str = "config.yaml"): + """ + Load configuration from YAML file. + + Args: + config_path: Path to config.yaml file + """ + self.config_path = config_path + self._load_config() + + def _load_config(self): + """Load and parse configuration file.""" + with open(self.config_path, 'r') as f: + config = yaml.safe_load(f) + + # GitHub settings + github_config = config.get('github', {}) + self.gh_cli_path = github_config.get('cli_path', 'gh') + self.max_retries = github_config.get('max_retries', 3) + + # Repository settings + repo_config = config.get('repositories', {}) + self.core_repositories = repo_config.get('core', []) + self.custom_repositories = repo_config.get('custom', []) + self.domains_json_path = repo_config.get('domains_json', '') + self.domains_user_name = repo_config.get('domains_user_name', '') + + # Output settings + output_config = config.get('output', {}) + self.output_base_dir = output_config.get('base_dir', 'output') + self.snapshots_dir = os.path.join(self.output_base_dir, 'snapshots') + self.reports_dir = os.path.join(self.output_base_dir, 'reports') + + # Filter settings + self.pr_filters = config.get('pr_filters', {}) + self.workflow_filters = config.get('workflow_filters', {}) + self.cve_filters = config.get('cve_filters', {}) + self.issue_filters = config.get('issue_filters', {}) + + def get_all_repositories(self) -> List[str]: + """ + Get deduplicated list of all repositories to scan. + + Includes: core + owned domains.json repos + custom. + + Returns: + Sorted list of repository names in owner/repo format + """ + categorized = self.get_categorized_repositories() + repos = set() + for source_repos in categorized.values(): + repos.update(source_repos) + return sorted(repos) + + def get_categorized_repositories(self) -> Dict[str, List[str]]: + """ + Get repositories grouped by source. + + Returns: + Dict with keys 'core', 'domains', 'custom' mapping to repo lists. + 'domains' only includes repos where domains_user_name is a qe_owner. + """ + result: Dict[str, List[str]] = { + 'core': list(self.core_repositories), + 'domains': [], + 'custom': list(self.custom_repositories), + } + + if self.domains_json_path and os.path.exists(self.domains_json_path) and self.domains_user_name: + parser = RepositoryParser(self.domains_json_path) + for repo in parser.parse(): + if self.domains_user_name in repo.qe_owners: + result['domains'].append(repo.name) + + result['domains'].sort() + return result + + def get_extension_repositories(self) -> List[Repository]: + """ + Get all extension repositories from domains.json with metadata. + + Returns: + List of Repository objects + """ + if self.domains_json_path and os.path.exists(self.domains_json_path): + parser = RepositoryParser(self.domains_json_path) + return parser.parse() + return [] + + def get_pr_labels(self) -> List[str]: + """Get PR filter labels.""" + return self.pr_filters.get('labels', []) + + def get_pr_assignee_teams(self) -> List[str]: + """Get PR assignee teams.""" + return self.pr_filters.get('assignee_teams', []) + + def get_qe_reviewers(self) -> List[str]: + """Get individual QE reviewer GitHub usernames.""" + return self.pr_filters.get('qe_reviewers', []) + + def get_workflow_max_age_days(self) -> int: + """Get maximum age for workflow runs in days.""" + return self.workflow_filters.get('max_age_days', 7) + + def get_workflow_overnight_start_hour(self) -> int: + """Get the start hour for the overnight window (yesterday at this hour).""" + return self.workflow_filters.get('overnight_start_hour', 18) + + def get_workflow_default_name_pattern(self) -> str: + """Get the default substring pattern for matching workflow names.""" + return self.workflow_filters.get('default_name_pattern', 'e2e') + + def get_workflow_repo_overrides(self) -> Dict[str, List[str]]: + """Get per-repo workflow name overrides.""" + return self.workflow_filters.get('repo_workflows', {}) or {} + + def get_workflow_exclusions(self) -> List[str]: + """Get workflow names to always exclude (case-insensitive substring match).""" + return self.workflow_filters.get('exclude_workflows', []) + + def get_cve_states(self) -> List[str]: + """Get CVE filter states.""" + return self.cve_filters.get('states', ['open']) + + def get_cve_severities(self) -> List[str]: + """Get CVE filter severities.""" + return self.cve_filters.get('severities', ['critical', 'high', 'medium', 'low']) + + def get_issue_labels(self) -> List[str]: + """Get issue filter labels.""" + return self.issue_filters.get('labels', ['kind/bug']) + + def get_issue_states(self) -> List[str]: + """Get issue filter states.""" + return self.issue_filters.get('states', ['open']) + + def get_issue_max_age_days(self) -> int: + """Get maximum age for issues in days.""" + return self.issue_filters.get('max_age_days', 30) diff --git a/qe-on-duty-reporter/qe_on_duty/gh_client.py b/qe-on-duty-reporter/qe_on_duty/gh_client.py new file mode 100644 index 00000000..a6c6bf38 --- /dev/null +++ b/qe-on-duty-reporter/qe_on_duty/gh_client.py @@ -0,0 +1,202 @@ +"""GitHub CLI wrapper for executing gh commands.""" + +import subprocess +import json +from typing import List, Dict, Any, Optional + + +class GHClient: + """Wrapper for GitHub CLI (gh) commands.""" + + def __init__(self, gh_path: str = "gh", max_retries: int = 3): + """ + Initialize GitHub CLI client. + + Args: + gh_path: Path to gh CLI binary (default: "gh" in PATH) + max_retries: Maximum number of retries for failed commands + """ + self.gh_path = gh_path + self.max_retries = max_retries + self._verify_gh_auth() + + def _verify_gh_auth(self): + """Verify gh CLI is authenticated.""" + result = subprocess.run( + [self.gh_path, "auth", "status"], + capture_output=True, + text=True + ) + if result.returncode != 0: + raise RuntimeError( + "gh CLI not authenticated. Run: gh auth login\n" + f"Error: {result.stderr}" + ) + + def run_command(self, args: List[str], retry_count: int = 0) -> Any: + """ + Execute gh command and return parsed output. + + Args: + args: Command arguments (e.g., ['pr', 'list', '--repo', 'owner/repo', '--json', 'number,title']) + retry_count: Current retry attempt + + Returns: + Parsed JSON output or empty list on error + + Raises: + RuntimeError: If command fails after max retries + """ + try: + result = subprocess.run( + [self.gh_path] + args, + capture_output=True, + text=True, + timeout=60 # 60 second timeout per command + ) + + if result.returncode == 0: + # Success - parse and return JSON + if result.stdout.strip(): + return json.loads(result.stdout) + return [] + + # Handle specific error cases + stderr_lower = result.stderr.lower() + + # Repository not found or no access - return empty list (not an error) + if any(phrase in stderr_lower for phrase in [ + "not found", + "could not resolve to a repository", + "no access", + "resource protected" + ]): + return [] + + # Dependabot alerts not available - return empty list (not an error) + if "dependabot" in stderr_lower and ("forbidden" in stderr_lower or "404" in stderr_lower): + return [] + + # No workflow runs - return empty list (valid state) + if "no workflow runs found" in stderr_lower: + return [] + + # Rate limiting - retry + if "rate limit" in stderr_lower or "api rate limit" in stderr_lower: + if retry_count < self.max_retries: + print(f"⏱️ Rate limited. Waiting 60s before retry {retry_count + 1}/{self.max_retries}...") + import time + time.sleep(60) + return self.run_command(args, retry_count + 1) + raise RuntimeError(f"Rate limited after {self.max_retries} retries") + + # Other errors - raise exception + raise RuntimeError( + f"gh command failed (exit code {result.returncode}): {result.stderr}" + ) + + except subprocess.TimeoutExpired: + if retry_count < self.max_retries: + print(f"⏱️ Command timed out. Retrying {retry_count + 1}/{self.max_retries}...") + return self.run_command(args, retry_count + 1) + raise RuntimeError(f"Command timed out after {self.max_retries} retries") + + except json.JSONDecodeError as e: + raise RuntimeError(f"Failed to parse gh command output as JSON: {e}") + + def get_current_user(self) -> str: + """Get the GitHub username of the currently authenticated user.""" + result = subprocess.run( + [self.gh_path, "api", "user", "--jq", ".login"], + capture_output=True, + text=True, + timeout=30 + ) + if result.returncode != 0: + raise RuntimeError(f"Failed to get current user: {result.stderr}") + return result.stdout.strip() + + def pr_list(self, repo: str, state: str = "open", limit: int = 100) -> List[Dict[str, Any]]: + """ + List pull requests for a repository. + + Args: + repo: Repository in owner/repo format + state: PR state (open, closed, all) + limit: Maximum number of PRs to fetch + + Returns: + List of PR data dictionaries + """ + return self.run_command([ + "pr", "list", + "--repo", repo, + "--state", state, + "--limit", str(limit), + "--json", "number,title,url,author,labels,assignees,createdAt,updatedAt,state,isDraft" + ]) + + def run_list(self, repo: str, limit: int = 100, status: Optional[str] = None, + created: Optional[str] = None) -> List[Dict[str, Any]]: + """ + List workflow runs for a repository. + + Args: + repo: Repository in owner/repo format + limit: Maximum number of runs to fetch + status: Filter by status/conclusion (e.g., 'failure', 'success', 'completed') + created: Date filter (e.g., '>=2026-07-12') + + Returns: + List of workflow run data dictionaries + """ + args = [ + "run", "list", + "--repo", repo, + "--limit", str(limit), + "--json", "databaseId,name,status,conclusion,workflowName,headBranch,headSha,createdAt,updatedAt,url" + ] + if status: + args.extend(["--status", status]) + if created: + args.extend(["--created", created]) + return self.run_command(args) + + def issue_list(self, repo: str, labels: Optional[List[str]] = None, state: str = "open", limit: int = 100) -> List[Dict[str, Any]]: + """ + List issues for a repository. + + Args: + repo: Repository in owner/repo format + labels: Filter by labels + state: Issue state (open, closed, all) + limit: Maximum number of issues to fetch + + Returns: + List of issue data dictionaries + """ + args = [ + "issue", "list", + "--repo", repo, + "--state", state, + "--limit", str(limit), + "--json", "number,title,url,author,labels,assignees,state,createdAt,updatedAt,comments" + ] + + if labels: + for label in labels: + args.extend(["--label", label]) + + return self.run_command(args) + + def api(self, endpoint: str) -> Any: + """ + Execute raw GitHub API request using gh api. + + Args: + endpoint: API endpoint (e.g., /repos/owner/repo/dependabot/alerts) + + Returns: + Parsed JSON response + """ + return self.run_command(["api", endpoint]) diff --git a/qe-on-duty-reporter/qe_on_duty/models/__init__.py b/qe-on-duty-reporter/qe_on_duty/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qe-on-duty-reporter/qe_on_duty/models/snapshot.py b/qe-on-duty-reporter/qe_on_duty/models/snapshot.py new file mode 100644 index 00000000..960515c7 --- /dev/null +++ b/qe-on-duty-reporter/qe_on_duty/models/snapshot.py @@ -0,0 +1,119 @@ +"""Data models for QE on-duty snapshots and reports.""" + +from dataclasses import dataclass, asdict, field +from typing import List +from datetime import datetime +import json + + +@dataclass +class PRData: + """Pull request data model.""" + repository: str + number: int + title: str + url: str + author: str + labels: List[str] + assignees: List[str] + created_at: str + updated_at: str + state: str + draft: bool + + +@dataclass +class WorkflowData: + """Workflow run data model.""" + repository: str + workflow_name: str + workflow_id: int + run_number: int + run_id: int + status: str # completed, in_progress, queued + conclusion: str # success, failure, cancelled, skipped + branch: str + commit_sha: str + url: str + started_at: str + completed_at: str + is_fallback: bool = False # True when outside the overnight window (latest run used as fallback) + + +@dataclass +class CVEData: + """CVE/Dependabot alert data model.""" + repository: str + alert_number: int + state: str # open, dismissed, fixed + severity: str # critical, high, medium, low + cve_id: str + package_name: str + vulnerable_version: str + patched_version: str + url: str + created_at: str + updated_at: str + + +@dataclass +class IssueData: + """Issue data model.""" + repository: str + number: int + title: str + url: str + author: str + labels: List[str] + assignees: List[str] + state: str + created_at: str + updated_at: str + comments_count: int + + +@dataclass +class SummaryData: + """Summary statistics for a snapshot.""" + total_prs_needing_qe: int + total_workflow_runs: int + failed_workflow_runs: int + critical_cves: int + high_cves: int + total_open_bugs: int + + +@dataclass +class DailySnapshot: + """Complete snapshot of QE on-duty data at a specific point in time.""" + date: str # ISO format: YYYY-MM-DD + time: str # HH:MM format + timestamp: str # ISO format with timezone + prs: List[PRData] = field(default_factory=list) + workflows: List[WorkflowData] = field(default_factory=list) + cves: List[CVEData] = field(default_factory=list) + issues: List[IssueData] = field(default_factory=list) + summary: SummaryData = None + + def to_json(self) -> str: + """Serialize to JSON string.""" + return json.dumps(asdict(self), indent=2) + + def save(self, filepath: str): + """Save snapshot to JSON file.""" + with open(filepath, 'w') as f: + f.write(self.to_json()) + + @classmethod + def load(cls, filepath: str) -> 'DailySnapshot': + """Load snapshot from JSON file.""" + with open(filepath, 'r') as f: + data = json.load(f) + # Convert nested dicts back to dataclasses + data['prs'] = [PRData(**pr) for pr in data.get('prs', [])] + data['workflows'] = [WorkflowData(**wf) for wf in data.get('workflows', [])] + data['cves'] = [CVEData(**cve) for cve in data.get('cves', [])] + data['issues'] = [IssueData(**issue) for issue in data.get('issues', [])] + if data.get('summary'): + data['summary'] = SummaryData(**data['summary']) + return cls(**data) diff --git a/qe-on-duty-reporter/qe_on_duty/reporters/__init__.py b/qe-on-duty-reporter/qe_on_duty/reporters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qe-on-duty-reporter/qe_on_duty/reporters/daily_reporter.py b/qe-on-duty-reporter/qe_on_duty/reporters/daily_reporter.py new file mode 100644 index 00000000..a6068f43 --- /dev/null +++ b/qe-on-duty-reporter/qe_on_duty/reporters/daily_reporter.py @@ -0,0 +1,283 @@ +"""Daily markdown report generator.""" + +from typing import List, Dict +from collections import defaultdict +from datetime import datetime, timedelta +from qe_on_duty.models.snapshot import DailySnapshot, WorkflowData +from qe_on_duty.config import Config + + +class DailyReporter: + """Generate daily markdown reports from snapshots.""" + + def __init__(self, snapshot: DailySnapshot, config: Config): + """ + Initialize daily reporter. + + Args: + snapshot: Daily snapshot data + config: Configuration object + """ + self.snapshot = snapshot + self.config = config + + def generate(self) -> str: + """ + Generate markdown report. + + Returns: + Markdown formatted report string + """ + sections = [ + self._generate_header(), + self._generate_summary(), + self._generate_pr_section(), + self._generate_workflow_section(), + self._generate_cve_section(), + self._generate_issue_section(), + self._generate_footer() + ] + + return "\n\n---\n\n".join(filter(None, sections)) + + def save(self, filepath: str): + """ + Save report to markdown file. + + Args: + filepath: Path to save the report + """ + with open(filepath, 'w') as f: + f.write(self.generate()) + + def _generate_header(self) -> str: + """Generate report header.""" + return f"# QE On-Duty Report - {self.snapshot.date} {self.snapshot.time}\n\n**Generated**: {self.snapshot.timestamp}" + + def _generate_summary(self) -> str: + """Generate summary section.""" + summary = self.snapshot.summary + if not summary: + return "## Summary\n\nNo data available." + + return f"""## Summary + +- 🔍 **PRs Needing QE Review**: {summary.total_prs_needing_qe} +- {self._get_workflow_emoji(summary)} **Overnight Workflows**: {summary.failed_workflow_runs} failed / {summary.total_workflow_runs} total +- {self._get_cve_emoji(summary)} **CVEs**: {summary.critical_cves} Critical, {summary.high_cves} High +- 🐛 **Open Bugs**: {summary.total_open_bugs}""" + + def _get_workflow_emoji(self, summary) -> str: + """Get emoji for workflow status.""" + if summary.failed_workflow_runs == 0: + return "✅" + elif summary.failed_workflow_runs < 5: + return "⚠️" + else: + return "❌" + + def _get_cve_emoji(self, summary) -> str: + """Get emoji for CVE status.""" + if summary.critical_cves > 0: + return "❌" + elif summary.high_cves > 0: + return "⚠️" + else: + return "✅" + + def _generate_pr_section(self) -> str: + """Generate pull requests section.""" + if not self.snapshot.prs: + return "## 1. Pull Requests Requiring QE Review\n\n*No PRs requiring QE review found.*" + + # Group PRs by repository + prs_by_repo = defaultdict(list) + for pr in self.snapshot.prs: + prs_by_repo[pr.repository].append(pr) + + sections = ["## 1. Pull Requests Requiring QE Review\n"] + + for repo in sorted(prs_by_repo.keys()): + sections.append(f"### {repo}") + for pr in prs_by_repo[repo]: + draft_marker = " 🚧 (Draft)" if pr.draft else "" + labels = ", ".join(f"`{label}`" for label in pr.labels if label in self.config.get_pr_labels()) + assignees_str = ", ".join(f"@{a}" for a in pr.assignees) if pr.assignees else "none" + sections.append( + f"- 🔍 **[#{pr.number}]({pr.url})** {pr.title}{draft_marker}\n" + f" - Labels: {labels if labels else 'none'}\n" + f" - Author: @{pr.author}\n" + f" - Assignees: {assignees_str}\n" + f" - Updated: {pr.updated_at}" + ) + + return "\n\n".join(sections) + + def _get_overnight_window_str(self) -> str: + """Format the overnight window for display.""" + start_hour = self.config.get_workflow_overnight_start_hour() + now = datetime.now() + cutoff = (now - timedelta(days=1)).replace(hour=start_hour, minute=0, second=0, microsecond=0) + return f"{cutoff.strftime('%Y-%m-%d %H:%M')} — {now.strftime('%Y-%m-%d %H:%M')}" + + def _generate_workflow_section(self) -> str: + """Generate CI/CD workflow section (failed overnight + fallback latest).""" + window = self._get_overnight_window_str() + overnight = [wf for wf in self.snapshot.workflows if not wf.is_fallback] + overnight_failed = [wf for wf in overnight if wf.conclusion == 'failure'] + fallback = [wf for wf in self.snapshot.workflows if wf.is_fallback] + + sections = [f"## 2. Workflow Runs (Overnight)\n\n**Window**: {window}\n"] + + if overnight_failed: + sections.append("### Failed Runs\n") + self._append_grouped_workflows(sections, overnight_failed) + else: + sections.append("*No failed workflow runs in the overnight window.*\n") + + if fallback: + sections.append("### Latest Runs (no overnight activity)\n") + self._append_grouped_workflows(sections, fallback) + + return "\n\n".join(sections) + + def _append_grouped_workflows(self, sections: list, workflows: List[WorkflowData]): + """Group workflows by repo and append formatted blocks to sections.""" + core_repos = set(self.config.core_repositories) + by_repo = defaultdict(list) + for wf in workflows: + by_repo[wf.repository].append(wf) + + core = sorted(r for r in by_repo if r in core_repos) + ext = sorted(r for r in by_repo if r not in core_repos) + + for repo in core + ext: + sections.append(self._format_repo_workflows(repo, by_repo[repo])) + + def _format_repo_workflows(self, repo: str, workflows: List[WorkflowData]) -> str: + """Format workflows for a single repository.""" + lines = [f"#### {repo}"] + + for wf in workflows[:10]: + emoji = self._get_status_emoji(wf.conclusion) + date_str = wf.started_at[:10] if wf.started_at else "" + fallback_marker = f" *(latest, {date_str})*" if wf.is_fallback else "" + lines.append( + f"- {emoji} **{wf.workflow_name}** (#{wf.run_number}) - {wf.conclusion}{fallback_marker} - [Link]({wf.url})" + ) + + if len(workflows) > 10: + lines.append(f"\n *...and {len(workflows) - 10} more runs*") + + return "\n".join(lines) + + def _get_status_emoji(self, conclusion: str) -> str: + """Map workflow conclusion to emoji.""" + emoji_map = { + 'success': '✅', + 'failure': '❌', + 'cancelled': '⚠️', + 'skipped': '⏭️', + 'neutral': '➖', + 'timed_out': '⏱️', + } + return emoji_map.get(conclusion, '❓') + + def _generate_cve_section(self) -> str: + """Generate CVE/security alerts section.""" + if not self.snapshot.cves: + return "## 3. Security Alerts (CVEs)\n\n*No open CVE alerts found.*" + + # Group by severity + cves_by_severity = defaultdict(list) + for cve in self.snapshot.cves: + cves_by_severity[cve.severity].append(cve) + + sections = ["## 3. Security Alerts (CVEs)\n"] + + # Order by severity + for severity in ['critical', 'high', 'medium', 'low']: + if severity in cves_by_severity: + emoji = "❌" if severity == "critical" else "⚠️" if severity == "high" else "ℹ️" + sections.append(f"### {emoji} {severity.capitalize()} Severity\n") + + for cve in cves_by_severity[severity]: + sections.append( + f"- **[{cve.cve_id}]({cve.url})** in `{cve.repository}`\n" + f" - Package: `{cve.package_name}` (vulnerable: {cve.vulnerable_version}, patched: {cve.patched_version})\n" + f" - Alert #{cve.alert_number}" + ) + + return "\n\n".join(sections) + + def _generate_issue_section(self) -> str: + """Generate open bugs section.""" + if not self.snapshot.issues: + return "## 4. Open Bugs\n\n*No open bugs found.*" + + # Group by repository + issues_by_repo = defaultdict(list) + for issue in self.snapshot.issues: + issues_by_repo[issue.repository].append(issue) + + sections = ["## 4. Open Bugs\n"] + + for repo in sorted(issues_by_repo.keys()): + issues = issues_by_repo[repo] + sections.append(f"### {repo} ({len(issues)} bugs)\n") + + for issue in issues[:10]: # Limit to 10 per repo + sections.append( + f"- 🐛 **[#{issue.number}]({issue.url})** {issue.title}\n" + f" - Created: {issue.created_at} | Comments: {issue.comments_count}" + ) + + if len(issues) > 10: + sections.append(f"\n *...and {len(issues) - 10} more bugs*") + + return "\n\n".join(sections) + + def _generate_footer(self) -> str: + """Generate report footer with repository listing and workflow filter config.""" + categorized = self.config.get_categorized_repositories() + all_repos = self.config.get_all_repositories() + + lines = [f"## Report Configuration\n"] + + # Repository coverage + lines.append(f"### Repositories ({len(all_repos)} total)\n") + + if categorized['core']: + lines.append(f"**Core** ({len(categorized['core'])})") + for repo in categorized['core']: + lines.append(f"- {repo}") + + if categorized['domains']: + lines.append(f"\n**Domains.json — owned by {self.config.domains_user_name}** ({len(categorized['domains'])})") + for repo in categorized['domains']: + lines.append(f"- {repo}") + + if categorized['custom']: + lines.append(f"\n**Custom** ({len(categorized['custom'])})") + for repo in categorized['custom']: + lines.append(f"- {repo}") + + # Workflow filters + lines.append(f"\n### Workflow Filters\n") + lines.append(f"- **Overnight window**: yesterday {self.config.get_workflow_overnight_start_hour()}:00 — now") + lines.append(f"- **Include pattern**: workflows matching `{self.config.get_workflow_default_name_pattern()}` (case-insensitive)") + + exclusions = self.config.get_workflow_exclusions() + if exclusions: + lines.append(f"- **Excluded**: {', '.join(f'`{e}`' for e in exclusions)}") + + overrides = self.config.get_workflow_repo_overrides() + if overrides: + lines.append(f"\n**Additional per-repo workflows** (on top of default pattern):") + for repo, workflows in sorted(overrides.items()): + lines.append(f"- `{repo}`: {', '.join(f'`{w}`' for w in workflows)}") + + lines.append("\n---\n") + lines.append("*Generated by QE On-Duty automation script*") + + return "\n".join(lines) diff --git a/qe-on-duty-reporter/qe_on_duty/reporters/weekly_reporter.py b/qe-on-duty-reporter/qe_on_duty/reporters/weekly_reporter.py new file mode 100644 index 00000000..15d0a649 --- /dev/null +++ b/qe-on-duty-reporter/qe_on_duty/reporters/weekly_reporter.py @@ -0,0 +1,190 @@ +"""Weekly summary report generator.""" + +from typing import List, Dict +from collections import defaultdict, Counter +from qe_on_duty.models.snapshot import DailySnapshot +from qe_on_duty.config import Config + + +class WeeklyReporter: + """Generate weekly summary reports from daily snapshots.""" + + def __init__(self, snapshots: List[DailySnapshot], config: Config): + """ + Initialize weekly reporter. + + Args: + snapshots: List of daily snapshots (latest from each day) + config: Configuration object + """ + self.snapshots = sorted(snapshots, key=lambda s: s.timestamp) + self.config = config + + def generate(self) -> str: + """ + Generate weekly summary markdown report. + + Returns: + Markdown formatted report string + """ + if not self.snapshots: + return "# Weekly Summary\n\n*No snapshots available for this week.*" + + sections = [ + self._generate_header(), + self._generate_highlights(), + self._generate_active_repos(), + self._generate_failing_workflows(), + self._generate_cve_trends(), + self._generate_daily_links(), + self._generate_footer() + ] + + return "\n\n---\n\n".join(filter(None, sections)) + + def save(self, filepath: str): + """ + Save report to markdown file. + + Args: + filepath: Path to save the report + """ + with open(filepath, 'w') as f: + f.write(self.generate()) + + def _generate_header(self) -> str: + """Generate report header.""" + if not self.snapshots: + return "# QE On-Duty Weekly Summary" + + start_date = self.snapshots[0].date + end_date = self.snapshots[-1].date + week_num = self.snapshots[-1].timestamp[:4] + "-week-" + str(int(self.snapshots[-1].timestamp[5:7]) // 7 * 4 + int(self.snapshots[-1].timestamp[8:10]) // 7) + + return f"""# QE On-Duty Weekly Summary - Week {week_num} + +**Period**: {start_date} to {end_date} +**Snapshots**: {len(self.snapshots)} days""" + + def _generate_highlights(self) -> str: + """Generate weekly highlights.""" + if not self.snapshots: + return "" + + # Calculate averages + avg_prs = sum(s.summary.total_prs_needing_qe for s in self.snapshots if s.summary) / len(self.snapshots) + total_workflows = sum(s.summary.total_workflow_runs for s in self.snapshots if s.summary) + total_failed = sum(s.summary.failed_workflow_runs for s in self.snapshots if s.summary) + success_rate = ((total_workflows - total_failed) / total_workflows * 100) if total_workflows > 0 else 0 + + total_critical_cves = sum(s.summary.critical_cves for s in self.snapshots if s.summary) + total_high_cves = sum(s.summary.high_cves for s in self.snapshots if s.summary) + total_bugs = sum(s.summary.total_open_bugs for s in self.snapshots if s.summary) + + return f"""## Weekly Highlights + +- 📊 **Average PRs/day needing QE**: {avg_prs:.1f} +- ✅ **Overall Workflow Success Rate**: {success_rate:.1f}% +- 🔒 **CVEs**: {total_critical_cves} Critical, {total_high_cves} High +- 🐛 **Average Open Bugs**: {total_bugs / len(self.snapshots):.0f}""" + + def _generate_active_repos(self) -> str: + """Generate most active repositories section.""" + pr_counts = Counter() + workflow_counts = Counter() + + for snapshot in self.snapshots: + for pr in snapshot.prs: + pr_counts[pr.repository] += 1 + for wf in snapshot.workflows: + workflow_counts[wf.repository] += 1 + + if not pr_counts and not workflow_counts: + return "" + + sections = ["## Most Active Repositories\n"] + + # Combine and rank + repo_activity = {} + for repo in set(pr_counts.keys()) | set(workflow_counts.keys()): + repo_activity[repo] = (pr_counts[repo], workflow_counts[repo]) + + # Sort by total activity + sorted_repos = sorted( + repo_activity.items(), + key=lambda x: x[1][0] + x[1][1], + reverse=True + ) + + for i, (repo, (prs, workflows)) in enumerate(sorted_repos[:10], 1): + sections.append(f"{i}. **{repo}** - {prs} PRs, {workflows} workflow runs") + + return "\n".join(sections) + + def _generate_failing_workflows(self) -> str: + """Generate consistently failing workflows section.""" + # Count failures by workflow + repo + workflow_failures = defaultdict(lambda: {'failed': 0, 'total': 0}) + + for snapshot in self.snapshots: + for wf in snapshot.workflows: + key = f"{wf.repository} → {wf.workflow_name}" + workflow_failures[key]['total'] += 1 + if wf.conclusion == 'failure': + workflow_failures[key]['failed'] += 1 + + # Find workflows that failed multiple times + consistently_failing = [] + for workflow, counts in workflow_failures.items(): + if counts['failed'] >= 3: # Failed 3+ times + consistently_failing.append((workflow, counts['failed'], counts['total'])) + + if not consistently_failing: + return "## Consistently Failing Workflows\n\n*No workflows failed consistently this week.*" + + sections = ["## Consistently Failing Workflows\n"] + + # Sort by failure count + for workflow, failed, total in sorted(consistently_failing, key=lambda x: x[1], reverse=True): + sections.append(f"- ❌ **{workflow}** - failed {failed}/{total} times") + + return "\n".join(sections) + + def _generate_cve_trends(self) -> str: + """Generate CVE trends section.""" + if not self.snapshots: + return "" + + # Count CVEs by severity across all snapshots + cve_by_severity = defaultdict(set) + + for snapshot in self.snapshots: + for cve in snapshot.cves: + cve_key = f"{cve.repository}:{cve.cve_id}" + cve_by_severity[cve.severity].add(cve_key) + + if not any(cve_by_severity.values()): + return "## CVE Summary\n\n*No CVEs found this week.*" + + return f"""## CVE Summary + +- ❌ **Critical**: {len(cve_by_severity['critical'])} unique alerts +- ⚠️ **High**: {len(cve_by_severity['high'])} unique alerts +- ℹ️ **Medium**: {len(cve_by_severity['medium'])} unique alerts +- ℹ️ **Low**: {len(cve_by_severity['low'])} unique alerts""" + + def _generate_daily_links(self) -> str: + """Generate links to daily reports.""" + if not self.snapshots: + return "" + + sections = ["## Daily Reports\n"] + + for snapshot in self.snapshots: + sections.append(f"- [{snapshot.date} {snapshot.time}](../daily/{snapshot.date}/{snapshot.time}.md)") + + return "\n".join(sections) + + def _generate_footer(self) -> str: + """Generate report footer.""" + return "*Generated by QE On-Duty automation script*" diff --git a/qe-on-duty-reporter/qe_on_duty/utils/__init__.py b/qe-on-duty-reporter/qe_on_duty/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qe-on-duty-reporter/qe_on_duty/utils/repository_parser.py b/qe-on-duty-reporter/qe_on_duty/utils/repository_parser.py new file mode 100644 index 00000000..474bf326 --- /dev/null +++ b/qe-on-duty-reporter/qe_on_duty/utils/repository_parser.py @@ -0,0 +1,81 @@ +"""Parse domains.json to extract repository information.""" + +import json +import re +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass +class Repository: + """Repository information.""" + name: str # owner/repo format + domain: str + owners: List[str] + qe_owners: List[str] + + +class RepositoryParser: + """Parse domains.json file to extract repository information.""" + + def __init__(self, domains_path: str): + """ + Initialize repository parser. + + Args: + domains_path: Path to domains.json file + """ + self.domains_path = domains_path + + def parse(self) -> List[Repository]: + """ + Parse domains.json and extract all repositories. + + Returns: + List of Repository objects + """ + with open(self.domains_path, 'r') as f: + domains = json.load(f) + + repositories = [] + for entry in domains: + if 'repository' in entry and entry['repository']: + repo_name = self._parse_github_url(entry['repository']) + if repo_name: + repositories.append(Repository( + name=repo_name, + domain=entry.get('domain', ''), + owners=entry.get('owners', []), + qe_owners=entry.get('qe_owners', []) + )) + + return repositories + + def _parse_github_url(self, url: str) -> Optional[str]: + """ + Convert GitHub URL to owner/repo format. + + Args: + url: GitHub repository URL (e.g., https://github.com/owner/repo) + + Returns: + Repository in owner/repo format, or None if parsing fails + """ + if not url: + return None + + # Extract owner/repo from various GitHub URL formats + # https://github.com/owner/repo + # https://github.com/owner/repo.git + # git@github.com:owner/repo.git + patterns = [ + r'https://github\.com/([^/]+/[^/\.]+)', + r'git@github\.com:([^/]+/[^/\.]+)', + ] + + for pattern in patterns: + match = re.match(pattern, url) + if match: + return match.group(1) + + return None diff --git a/qe-on-duty-reporter/requirements.md b/qe-on-duty-reporter/requirements.md new file mode 100644 index 00000000..ace91171 --- /dev/null +++ b/qe-on-duty-reporter/requirements.md @@ -0,0 +1,33 @@ +# Podman Desktop QE on-duty rotation responsibilities + +Why? Having a rotating schedule for these responsibilities ensures a balanced distribution of tasks among the QE team, promoting skill development, team collaboration and team member exposure to a broader engineering team. + +### Possible actions to take +* Monitor PRs incoming and outstanding that could require our review +* Verification of the PRs +* Look for changes that could break tests, can apply qe/review label +* Especially monitor qe/testing-required label on PRs +* Monitor also PRs with assignee group: qe-reviewers +* Issues Management and creation +* Help reproducing bugs +* Verification of the patches +* Filling issues for extending tests related to changes in application, for example these with qe/review label + +### Podman Desktop QE Infrastructure Stability: +Monitor test results and maintain CI/CD systems and infrastructure + +Main repositories to look at for the GH actions workflow run results +* https://github.com/podman-desktop/podman-desktop +* https://github.com/podman-desktop/e2e + +Gathering the test results from the extensions GH Actions CI: +* go thourgh the list of extensions on domains.json file: https://github.com/containers/podman-desktop-internal/blob/main/domains.json + +Github available and required items for the summary: + +* Test results from podman-desktop repo +* test results from e2e repository +* possibly monitor e2e test results from the extensions +* PR reviews with qe/testing-required +* PR reviews with assignee: qe-reviewers +* CVEs opened on every repository we maintain (see domains) diff --git a/qe-on-duty-reporter/requirements.txt b/qe-on-duty-reporter/requirements.txt new file mode 100644 index 00000000..1a5788f2 --- /dev/null +++ b/qe-on-duty-reporter/requirements.txt @@ -0,0 +1,2 @@ +pyyaml>=6.0.1 +python-dateutil>=2.8.2 diff --git a/qe-on-duty-reporter/tests/__init__.py b/qe-on-duty-reporter/tests/__init__.py new file mode 100644 index 00000000..e69de29b From 134b723a863755d670d2070b328d1b9ec3fb3d46 Mon Sep 17 00:00:00 2001 From: Ondrej Dockal Date: Thu, 16 Jul 2026 14:44:44 +0200 Subject: [PATCH 2/2] chore: exclude qe-on-duty-reporter path from tkn build workflows Signed-off-by: Ondrej Dockal --- .github/workflows/build.yaml | 2 ++ .github/workflows/tkn-bundle.yaml | 2 ++ qe-on-duty-reporter/qe_on_duty/reporters/daily_reporter.py | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f8aa7b59..314ab9c6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -10,6 +10,7 @@ on: - '.fmf/**' - 'plans/**' - 'e2e-runner/**' + - 'qe-on-duty-reporter/**' - '!.github/workflows/build.yaml' pull_request: branches: [ main ] @@ -18,6 +19,7 @@ on: - '.fmf/**' - 'plans/**' - 'e2e-runner/**' + - 'qe-on-duty-reporter/**' - '!.github/workflows/build.yaml' jobs: diff --git a/.github/workflows/tkn-bundle.yaml b/.github/workflows/tkn-bundle.yaml index ababad84..8b9a6c91 100644 --- a/.github/workflows/tkn-bundle.yaml +++ b/.github/workflows/tkn-bundle.yaml @@ -10,6 +10,7 @@ on: - '.fmf/**' - 'plans/**' - 'e2e-runner/**' + - 'qe-on-duty-reporter/**' - '!.github/workflows/tkn-bundle.yaml' pull_request: branches: [ main ] @@ -18,6 +19,7 @@ on: - '.fmf/**' - 'plans/**' - 'e2e-runner/**' + - 'qe-on-duty-reporter/**' - '!.github/workflows/tkn-bundle.yaml' jobs: diff --git a/qe-on-duty-reporter/qe_on_duty/reporters/daily_reporter.py b/qe-on-duty-reporter/qe_on_duty/reporters/daily_reporter.py index a6068f43..51d15a19 100644 --- a/qe-on-duty-reporter/qe_on_duty/reporters/daily_reporter.py +++ b/qe-on-duty-reporter/qe_on_duty/reporters/daily_reporter.py @@ -135,6 +135,11 @@ def _generate_workflow_section(self) -> str: else: sections.append("*No failed workflow runs in the overnight window.*\n") + overnight_passed = [wf for wf in overnight if wf.conclusion == 'success'] + if overnight_passed: + sections.append("### Passing Runs\n") + self._append_grouped_workflows(sections, overnight_passed) + if fallback: sections.append("### Latest Runs (no overnight activity)\n") self._append_grouped_workflows(sections, fallback)