"""Generate manifest diff reports between two TheRock commits. Compares submodule versions and generates HTML reports showing commit changes for each component between builds. Arguments: --start Start commit SHA or workflow run ID (required unless using --find-last-run or --pr-base-ref). --end End commit SHA or workflow run ID (required). --find-last-run Workflow filename (e.g., 'multi_arch_ci.yml'). When set, --start is resolved as the head SHA of that workflow's most recent run on --branch that concluded with 'success' or 'failure' (cancelled / skipped / in-progress runs are ignored). --pr-base-ref PR base branch name. When set, --start is resolved as the merge-base between --end and the named branch via the GitHub Compare API. Rebase-safe. --workflow-mode Treat --start and --end as workflow run IDs instead of commit SHAs. --branch Branch to scope --find-last-run lookups against (default: 'main'). Only consulted when --find-last-run is set. --output-dir Directory to write the HTML report into. If unset, falls back to the TheRock root directory. If no usable start ref can be derived, the script logs the reason and exits 0 without writing a report. Example usage: python build_tools/generate_manifest_diff_report.py --start abc123 --end def456 python build_tools/generate_manifest_diff_report.py --end def456 --find-last-run multi_arch_ci.yml python build_tools/generate_manifest_diff_report.py --end def456 --pr-base-ref main python build_tools/generate_manifest_diff_report.py --start 12345 --end 67890 --workflow-mode """ # Standard library imports import argparse import html import sys import urllib.parse from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from urllib.error import HTTPError # Establish script's location first (needed for local imports) THIS_SCRIPT_DIR = Path(__file__).resolve().parent THEROCK_DIR = THIS_SCRIPT_DIR.parent sys.path.insert(0, str(THIS_SCRIPT_DIR)) # Local imports from generate_therock_manifest import build_manifest_schema from github_actions.github_actions_api import ( gha_append_step_summary, gha_query_last_workflow_run, gha_query_workflow_run_by_id, gha_send_request, ) # GitHub API constants GITHUB_API_BASE = "https://api.github.com/repos" ROCM_ORG = "ROCm" THEROCK_REPO = "TheRock" # Superrepos requiring component-level processing SUPERREPO_NAMES = {"rocm-systems", "rocm-libraries"} # Directories to scan within superrepos for components (In case new directories are added, we need to add them here) SUPERREPO_COMPONENT_DIRS = ["shared", "projects"] # Pagination constants for GitHub API commit fetching. # MAX_PAGES * PER_PAGE = 2000 commits maximum. # This limit prevents excessive API calls while covering most reasonable commit ranges. # GitHub API returns commits in reverse chronological order, so we paginate until # we find the start commit or hit the limit. MAX_PAGES = 20 # Maximum number of API pages to fetch PER_PAGE = 100 # Commits per page (GitHub API maximum is 100) # Report identifiers UNASSIGNED_KEY = "Unassigned" # File paths HTML_TEMPLATE_PATH = THIS_SCRIPT_DIR / "manifest_diff_report_template.html" HTML_REPORT_PATH = THEROCK_DIR / "TheRockReport.html" # ============================================================================= # Domain Classes # ============================================================================= @dataclass class Component: """A component within a superrepo (projects/rocblas, shared/hipblaslt).""" path: str name: str status: str = "unchanged" # added, removed, unchanged, changed commits: list[dict] = field(default_factory=list) @dataclass class Submodule: """A submodule in TheRock manifest.""" name: str sha: str api_base: str branch: str status: str = "unchanged" # removed, added, unchanged, changed, reverted commits: list[dict] = field(default_factory=list) start_sha: str = "" end_sha: str = "" @dataclass class Superrepo(Submodule): """A superrepo submodule containing components (rocm-libraries, rocm-systems).""" components: dict[str, Component] = field(default_factory=dict) all_commits: list[dict] = field(default_factory=list) commit_allocation: dict[str, list[dict]] = field(default_factory=dict) @property def added_components(self) -> list[str]: return [c.name for c in self.components.values() if c.status == "added"] @property def removed_components(self) -> list[str]: return [c.name for c in self.components.values() if c.status == "removed"] @property def changed_components(self) -> list[str]: return [c.name for c in self.components.values() if c.status == "changed"] @property def unchanged_components(self) -> list[str]: return [c.name for c in self.components.values() if c.status == "unchanged"] @dataclass class ManifestDiff: """Result of comparing two TheRock manifests.""" start_commit: str end_commit: str submodules: dict[str, Submodule] = field(default_factory=dict) superrepos: dict[str, Superrepo] = field(default_factory=dict) @property def added(self) -> list[str]: return [s.name for s in self.submodules.values() if s.status == "added"] @property def removed(self) -> list[str]: return [s.name for s in self.submodules.values() if s.status == "removed"] @property def changed(self) -> list[str]: return [s.name for s in self.submodules.values() if s.status == "changed"] @property def unchanged(self) -> list[str]: return [s.name for s in self.submodules.values() if s.status == "unchanged"] @property def reverted(self) -> list[str]: return [s.name for s in self.submodules.values() if s.status == "reverted"] @property def all_items(self) -> dict[str, Submodule | Superrepo]: """All submodules and superrepos combined.""" return {**self.submodules, **self.superrepos} def get_status_groups(self) -> dict[str, list[str]]: """Return submodules grouped by status.""" groups: dict[str, list[str]] = { "removed": [], "added": [], "unchanged": [], "changed": [], "reverted": [], } for item in self.all_items.values(): groups[item.status].append(item.name) return groups # ============================================================================= # CLI and Commit Resolution # ============================================================================= def parse_args(argv: list[str] | None) -> argparse.Namespace: """Parse command line arguments.""" parser = argparse.ArgumentParser(description="Generate manifest diff report") parser.add_argument( "--start", required=False, help="Start workflow ID or commit SHA" ) parser.add_argument("--end", required=True, help="End workflow ID or commit SHA") parser.add_argument( "--find-last-run", help=( "Workflow filename (e.g. 'multi_arch_ci.yml'). When set, --start " "is resolved as the head SHA of that workflow's most recent run " "on --branch that concluded with 'success' or 'failure' " "(cancelled / skipped / in-progress runs are ignored)." ), ) parser.add_argument( "--pr-base-ref", help=( "PR base branch name. When set, --start is resolved as the " "merge-base between --end and this branch via the GitHub Compare " "API. Rebase-safe." ), ) parser.add_argument( "--workflow-mode", action="store_true", help="Treat --start and --end as workflow run IDs instead of commit SHAs", ) parser.add_argument( "--branch", default="main", help=( "Branch to scope --find-last-run lookups against (default: main). " "Only consulted when --find-last-run is set; ignored otherwise." ), ) parser.add_argument( "--output-dir", type=Path, help="Output directory for the report (default: TheRock root directory)", ) return parser.parse_args(argv) def _optional_str(val: str | None) -> str | None: """Normalize optional string: None or blank -> None, otherwise stripped. Workflow callers (e.g. GitHub Actions) may pass empty string for unused optional inputs; treat those as not provided. """ if val is None: return None s = val.strip() return s if s else None def resolve_commits( args: argparse.Namespace, ) -> tuple[str | None, str | None]: """Resolve start and end commit SHAs from arguments. Returns ``(None, None)`` when --find-last-run finds no prior matching run (graceful empty); caller should exit 0. Other API errors (e.g. Compare API 404 on --pr-base-ref) propagate as GitHubAPIError; the workflow's continue-on-error gate handles those. """ start = _optional_str(args.start) find_last = _optional_str(args.find_last_run) pr_base = _optional_str(args.pr_base_ref) end = _optional_str(args.end) if end is None: raise ValueError("--end is required") if start is None and find_last is None and pr_base is None: raise ValueError( "--start is required unless --find-last-run or --pr-base-ref is provided" ) therock = f"{ROCM_ORG}/{THEROCK_REPO}" # Resolve end first; --pr-base-ref needs end_sha to compute the merge-base. if args.workflow_mode: end_sha = gha_query_workflow_run_by_id(therock, end).get("head_sha") else: end_sha = end # Resolve start, in priority order: --pr-base-ref, --find-last-run, # --workflow-mode, then plain --start. if pr_base is not None: compare_url = ( f"https://api.github.com/repos/{therock}/compare/{pr_base}...{end_sha}" ) compare = gha_send_request(compare_url) start_sha = compare.get("merge_base_commit", {}).get("sha") elif find_last is not None: # Hardcoded to terminal-status: any run that ran to completion (success # or failure) is acceptable — devs comparing to "the last run that # actually ran" don't care whether it was green or red, only that it # wasn't cancelled / skipped / in-progress. accepted = {"success", "failure"} last_run = gha_query_last_workflow_run( therock, find_last, branch=args.branch, accepted_statuses=accepted, ) if last_run is None: print( f" [empty] No prior run of {find_last} on {args.branch} with " f"conclusion in {sorted(accepted)} (likely a first-ever run)." ) return None, None start_sha = last_run["head_sha"] elif args.workflow_mode: start_sha = gha_query_workflow_run_by_id(therock, start).get("head_sha") else: start_sha = start return start_sha, end_sha # ============================================================================= # GitHub API Utilities # ============================================================================= def get_api_base_from_url(url: str, fallback_name: str) -> str: """Convert git remote URL to GitHub API base URL.""" # To handle cases where urls are in ssh format, we need to convert them to https format normalized = url.replace("git@github.com:", "https://github.com/") parsed = urllib.parse.urlparse(normalized) if parsed.netloc == "github.com": path = parsed.path.lstrip("/") if path.endswith(".git"): path = path[:-4] return f"https://api.github.com/repos/{path}" print(f" Warning: Could not parse URL '{url}', using ROCm/{fallback_name}") return f"{GITHUB_API_BASE}/{ROCM_ORG}/{fallback_name}" def is_revert(old_sha: str, new_sha: str, api_base: str) -> bool: """Check if updating from old_sha to new_sha is a revert (going backwards). Uses GitHub compare API: compare/{new_sha}...{old_sha} - If old_sha is "ahead" of new_sha → old_sha came later → revert → return True - If old_sha is "behind" new_sha → normal forward progress → return False - If "diverged" → different branches, not a revert → return False - If 404 → commits deleted from repo → return False (can't determine) """ try: # If old_sha is "ahead" of new_sha, we're moving backwards (revert) compare = gha_send_request(f"{api_base}/compare/{new_sha}...{old_sha}") status = compare.get("status", "") return status == "ahead" except HTTPError as e: # 404 = commits deleted from repo (hard orphaned, rare) # Other errors = network issues, rate limits, etc. if e.code == 404: print(" (Commits deleted from repo - cannot determine revert status)") return False except (KeyError, ValueError, TypeError) as e: print(f" Warning: Could not compare commits for revert detection: {e}") return False def fetch_commits_in_range( repo_name: str, start_sha: str, end_sha: str, api_base: str, ) -> list[dict]: """Fetch commits between two SHAs.""" commits: list[dict] = [] found_start = False page = 1 print(f" Fetching commits for {repo_name}: {start_sha[:7]} -> {end_sha[:7]}") while not found_start and page <= MAX_PAGES: params = {"sha": end_sha, "per_page": PER_PAGE, "page": page} url = f"{api_base}/commits?{urllib.parse.urlencode(params)}" try: data = gha_send_request(url) if not data: break for commit in data: if commit["sha"] == start_sha: found_start = True break commits.append(commit) if len(data) < PER_PAGE: break page += 1 except (KeyError, ValueError, TypeError) as e: print(f" Error fetching commits: {e}") break # Fallback for diverged/off-branch commits (soft orphaned) # This happens when commits exist in the repo but aren't in linear history, # e.g., after a rebase where the branch moved but old commits weren't deleted. # The compare API can still find commits unique to end_sha in these cases. if not found_start: try: compare_url = f"{api_base}/compare/{start_sha}...{end_sha}" compare_data = gha_send_request(compare_url) if compare_data.get("status") == "diverged": print(" (Commits are off-branch/diverged - using compare API)") diverged_commits = compare_data.get("commits", []) print(f" Found {len(diverged_commits)} commits unique to end") return diverged_commits except (KeyError, ValueError, TypeError, RuntimeError) as e: print(f" Warning: Could not use compare API fallback: {e}") print(f" Found {len(commits)} commits") return commits def fetch_superrepo_components( repo_name: str, commit_sha: str, api_base: str ) -> list[str]: """Get component paths from superrepo at given commit. Note: Some directories (e.g., shared/) may not exist at older commits. 404 errors are handled gracefully - directory is skipped. """ components: list[str] = [] for directory in SUPERREPO_COMPONENT_DIRS: url = f"{api_base}/contents/{directory}?ref={commit_sha}" try: data = gha_send_request(url) for item in data: if item["type"] == "dir": components.append(f"{directory}/{item['name']}") except HTTPError as e: if e.code == 404: print(f" {directory}/ not found at this commit (skipping)") else: print(f" HTTP {e.code} fetching {directory}/") except (KeyError, ValueError, TypeError) as e: print(f" Failed to fetch {directory} folder: {e}") return components def fetch_commits_by_directory( repo_name: str, start_sha: str, end_sha: str, api_base: str, directories: list[str], ) -> tuple[dict[str, list[dict]], list[dict]]: """Get commits allocated by directory.""" print(f" Getting commits by directories for {repo_name}") all_commits = fetch_commits_in_range( repo_name=repo_name, start_sha=start_sha, end_sha=end_sha, api_base=api_base, ) commit_shas_in_range = {c["sha"] for c in all_commits} allocation: dict[str, list[dict]] = {} seen_shas: set[str] = set() for directory in directories: dir_key = directory.rstrip("/") allocation[dir_key] = [] page = 1 while page <= MAX_PAGES: params = { "sha": end_sha, "path": directory, "per_page": PER_PAGE, "page": page, } url = f"{api_base}/commits?{urllib.parse.urlencode(params)}" try: data = gha_send_request(url) if not data: break commits_found = 0 for commit in data: sha = commit["sha"] if sha in commit_shas_in_range: allocation[dir_key].append(commit) seen_shas.add(sha) commits_found += 1 if sha == start_sha: break if commits_found == 0 and page > 1: break if len(data) < PER_PAGE: break page += 1 except (KeyError, ValueError, TypeError) as e: print(f" Error querying directory {directory}: {e}") break if allocation[dir_key]: print(f" {dir_key}: {len(allocation[dir_key])} commits") # Unassigned commits unassigned = [c for c in all_commits if c["sha"] not in seen_shas] if unassigned: allocation[UNASSIGNED_KEY] = unassigned print(f" {UNASSIGNED_KEY}: {len(unassigned)} commits") return allocation, all_commits # ============================================================================= # Manifest Loading # ============================================================================= def load_submodules_at_commit(commit_sha: str) -> dict[str, dict[str, str]]: """Load all submodules from TheRock at a specific commit. Uses build_manifest_schema from generate_therock_manifest to get submodule information and converts it to the expected format. Note: Requires the repository to be checked out with full history (fetch-depth: 0) for historical commits to be accessible via local git. """ try: manifest = build_manifest_schema(THEROCK_DIR, commit_sha) submodules: dict[str, dict[str, str]] = {} for entry in manifest.get("submodules", []): name = entry["submodule_name"] url = entry["submodule_url"] pin_sha = entry.get("pin_sha") if pin_sha: submodules[name] = { "sha": pin_sha, "api_base": get_api_base_from_url(url, name), "branch": None, # Branch info not in manifest schema } print(f"Found {len(submodules)} submodules in manifest for {commit_sha[:8]}") return submodules except (RuntimeError, KeyError) as e: print(f"Error loading submodules for commit {commit_sha}: {e}") return {} # ============================================================================= # Status Determination # ============================================================================= def determine_status( old_sha: str | None, new_sha: str | None, api_base: str, ) -> tuple[str, str, str]: """Determine submodule status. Returns (status, fetch_start, fetch_end).""" if old_sha and not new_sha: return "removed", "", "" if new_sha and not old_sha: return "added", "", new_sha if old_sha == new_sha: return "unchanged", "", "" # Check for revert (is_revert handles orphaned/404 gracefully by returning False) if is_revert(old_sha, new_sha, api_base): return "reverted", new_sha, old_sha return "changed", old_sha, new_sha # ============================================================================= # Submodule Processing # ============================================================================= def process_regular_submodule( name: str, start_data: dict[str, str] | None, end_data: dict[str, str] | None, ) -> Submodule: """Process a regular (non-superrepo) submodule.""" old_sha = start_data["sha"] if start_data else None new_sha = end_data["sha"] if end_data else None data = end_data or start_data or {} api_base = data.get("api_base", "") branch = data.get("branch", "main") status, fetch_start, fetch_end = determine_status(old_sha, new_sha, api_base) submodule = Submodule( name=name, sha=new_sha or old_sha or "", api_base=api_base, branch=branch, status=status, start_sha=old_sha or "", end_sha=new_sha or "", ) if status == "removed": print(f" {name}: REMOVED") elif status == "added": print(f" {name}: ADDED -> {fetch_end[:7]}") # For a newly added submodule, we need to fetch only the tip commit try: tip_commit = gha_send_request(f"{api_base}/commits/{fetch_end}") submodule.commits = [tip_commit] if tip_commit else [] except (KeyError, ValueError, TypeError) as e: print(f" Warning: Could not fetch tip commit: {e}") elif status == "unchanged": print(f" {name}: UNCHANGED") elif status in ("changed", "reverted"): label = "REVERTED" if status == "reverted" else "CHANGED" print(f" {name}: {label} {fetch_start[:7]} -> {fetch_end[:7]}") submodule.commits = fetch_commits_in_range( repo_name=name, start_sha=fetch_start, end_sha=fetch_end, api_base=api_base, ) return submodule class SuperrepoProcessor: """Processes a superrepo with component-level analysis.""" def __init__( self, name: str, start_data: dict[str, str] | None, end_data: dict[str, str] | None, ): self.name = name self.start_data = start_data self.end_data = end_data self.old_sha = start_data["sha"] if start_data else None self.new_sha = end_data["sha"] if end_data else None data = end_data or start_data or {} self.api_base = data.get("api_base", "") self.branch = data.get("branch", "main") self.superrepo: Superrepo | None = None self.fetch_start: str | None = None self.fetch_end: str | None = None def process(self) -> Superrepo: """Process the superrepo and return the result.""" self.init_superrepo() print(f" {self.name}: SUPERREPO ({self.superrepo.status})") if self.superrepo.status == "removed": return self.handle_removed() elif self.superrepo.status == "added": return self.handle_added() elif self.superrepo.status == "unchanged": return self.handle_unchanged() else: return self.handle_changed_or_reverted() def init_superrepo(self) -> None: """Initialize the Superrepo object with basic info.""" status, self.fetch_start, self.fetch_end = determine_status( self.old_sha, self.new_sha, self.api_base ) self.superrepo = Superrepo( name=self.name, sha=self.new_sha or self.old_sha or "", api_base=self.api_base, branch=self.branch, status=status, start_sha=self.old_sha or "", end_sha=self.new_sha or "", ) def handle_removed(self) -> Superrepo: """Handle a removed superrepo.""" start_components = fetch_superrepo_components( self.name, self.old_sha, self.api_base ) print(f" Removed superrepo had {len(start_components)} components") for comp_path in start_components: self.add_component(comp_path, status="removed") return self.superrepo def handle_added(self) -> Superrepo: """Handle a newly added superrepo.""" end_components = fetch_superrepo_components( self.name, self.new_sha, self.api_base ) print(f" Added superrepo with {len(end_components)} components") print(f" Fetching tip commits for each component...") for comp_path in end_components: tip_commits = self.fetch_tip_commit(comp_path) self.add_component(comp_path, status="added", commits=tip_commits) return self.superrepo def handle_unchanged(self) -> Superrepo: """Handle an unchanged superrepo.""" end_components = fetch_superrepo_components( self.name, self.new_sha, self.api_base ) print(f" Unchanged superrepo with {len(end_components)} components") for comp_path in end_components: self.add_component(comp_path, status="unchanged") return self.superrepo def handle_changed_or_reverted(self) -> Superrepo: """Handle a changed or reverted superrepo with full commit analysis.""" start_components = ( fetch_superrepo_components(self.name, self.old_sha, self.api_base) if self.old_sha else [] ) end_components = ( fetch_superrepo_components(self.name, self.new_sha, self.api_base) if self.new_sha else [] ) start_set = set(start_components) end_set = set(end_components) added_paths = end_set - start_set removed_paths = start_set - end_set print( f" Components: {len(start_components)} -> {len(end_components)} " f"(+{len(added_paths)} -{len(removed_paths)})" ) all_components = start_set | end_set directories = [c + "/" if not c.endswith("/") else c for c in all_components] allocation, all_commits = fetch_commits_by_directory( repo_name=self.name, start_sha=self.fetch_start, end_sha=self.fetch_end, api_base=self.api_base, directories=directories, ) self.superrepo.all_commits = all_commits self.superrepo.commit_allocation = allocation for comp_path in all_components: comp_status, comp_commits = self.determine_component_status( comp_path, added_paths, removed_paths, allocation ) self.add_component(comp_path, status=comp_status, commits=comp_commits) return self.superrepo def determine_component_status( self, comp_path: str, added_paths: set[str], removed_paths: set[str], allocation: dict[str, list[dict]], ) -> tuple[str, list[dict]]: """Determine the status and commits for a component.""" comp_key = comp_path.rstrip("/") if comp_path in added_paths: comp_commits = allocation.get(comp_key, []) if comp_commits: comp_commits = [comp_commits[0]] return "added", comp_commits elif comp_path in removed_paths: return "removed", [] elif comp_key in allocation and allocation[comp_key]: return "changed", allocation.get(comp_key, []) else: return "unchanged", [] def fetch_tip_commit(self, comp_path: str) -> list[dict]: """Fetch the tip commit for a component.""" comp_name = comp_path.split("/")[-1] try: params = {"sha": self.new_sha, "path": comp_path, "per_page": 1} url = f"{self.api_base}/commits?{urllib.parse.urlencode(params)}" tip_data = gha_send_request(url) if tip_data: return [tip_data[0]] except (KeyError, ValueError, TypeError, HTTPError) as e: print(f" Warning: Could not fetch tip for {comp_name}: {e}") return [] def add_component( self, comp_path: str, status: str, commits: list[dict] | None = None ) -> None: """Add a component to the superrepo.""" comp_name = comp_path.split("/")[-1] self.superrepo.components[comp_path] = Component( path=comp_path, name=comp_name, status=status, commits=commits or [] ) def process_superrepo( name: str, start_data: dict[str, str] | None, end_data: dict[str, str] | None, ) -> Superrepo: """Process a superrepo with component-level analysis.""" return SuperrepoProcessor(name, start_data, end_data).process() # ============================================================================= # ManifestDiff Construction # ============================================================================= def compare_manifests(start_commit: str, end_commit: str) -> ManifestDiff: """Compare two TheRock commits and return a ManifestDiff.""" print(f"\nComparing commits: {start_commit[:7]} -> {end_commit[:7]}") print("\n=== Getting submodules for START commit ===") start_subs = load_submodules_at_commit(start_commit) print(f"\nFound {len(start_subs)} submodules for START commit") print("\n=== Getting submodules for END commit ===") end_subs = load_submodules_at_commit(end_commit) print(f"\nFound {len(end_subs)} submodules for END commit") diff = ManifestDiff(start_commit=start_commit, end_commit=end_commit) all_names = set(start_subs.keys()) | set(end_subs.keys()) print("\n=== Processing Submodules ===") for name in sorted(all_names): start_data = start_subs.get(name) end_data = end_subs.get(name) if name in SUPERREPO_NAMES: diff.superrepos[name] = process_superrepo(name, start_data, end_data) else: diff.submodules[name] = process_regular_submodule( name, start_data, end_data ) return diff # ============================================================================= # HTML Helpers # ============================================================================= def format_commit_date(date_string: str) -> str: """Format ISO date string to readable format.""" if date_string == "Unknown" or not date_string: return "Unknown" try: dt = datetime.fromisoformat(date_string.replace("Z", "+00:00")) return dt.strftime("%b %d, %Y") except (ValueError, TypeError): return date_string def create_commit_badge(sha: str, repo_name: str) -> str: """Create HTML badge for a commit SHA.""" short_sha = sha[:7] if sha not in ("-", "N/A", "") else sha or "N/A" url = f"https://github.com/ROCm/{repo_name}/commit/{sha}" return ( f"" f"{short_sha}" ) def extract_commit_data(commit: dict) -> dict[str, str]: """Extract commit data from GitHub API response.""" return { "sha": commit.get("sha", "-"), "message": commit.get("commit", {}).get("message", "-").split("\n")[0], "author": commit.get("commit", {}).get("author", {}).get("name", "Unknown"), "date": format_commit_date( commit.get("commit", {}).get("author", {}).get("date", "Unknown") ), } def create_commit_item_html(commit: dict, repo_name: str) -> str: """Create HTML for a single commit item.""" data = extract_commit_data(commit) badge = create_commit_badge(data["sha"], repo_name) return ( f"
{i}{replaced_list}| Date | SHA | " "Author | Project(s) | " "Message |
|---|