diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a389c77e20..3fbc9aa920 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -451,7 +451,8 @@ jobs: - name: Run feature version audit run: | - python tools/feature_version_audit.py --output feature-version-audit-latest.md 2>&1 | tee feature-version-audit-latest.md + python tools/feature_version_audit.py --output feature-version-audit-latest.md + cat feature-version-audit-latest.md - name: Upload feature audit markdown uses: actions/upload-artifact@v4 @@ -477,10 +478,16 @@ jobs: - name: Run feature version audit (PR check) id: audit - continue-on-error: true run: | - python tools/feature_version_audit.py --pr-check --base ${{ github.event.pull_request.base.sha }} 2>&1 | tee pr-feature-audit.md + set +e + python tools/feature_version_audit.py --pr-check --ci --base ${{ github.event.pull_request.base.sha }} > pr-feature-audit.md + EXIT_CODE=$? + set -e + cat pr-feature-audit.md + exit $EXIT_CODE + - name: Upload feature audit markdown (PR) + if: always() uses: actions/upload-artifact@v4 with: name: pr-feature-audit @@ -488,13 +495,14 @@ jobs: feature-audit-pr-comment: name: Comment on PR with audit results - if: github.event_name == 'pull_request_target' + if: always() && github.event_name == 'pull_request_target' && (needs.feature-audit-pr.result == 'success' || needs.feature-audit-pr.result == 'failure') needs: feature-audit-pr runs-on: ubuntu-latest permissions: pull-requests: write steps: - name: Download feature audit markdown (PR) + continue-on-error: true uses: actions/download-artifact@v4 with: name: pr-feature-audit diff --git a/tools/feature_version_audit.py b/tools/feature_version_audit.py index 540ed46e8b..5a8b3d91ca 100644 --- a/tools/feature_version_audit.py +++ b/tools/feature_version_audit.py @@ -33,8 +33,15 @@ RE_IS_CORE = re.compile(r"IsCore\s*\(.*\)\s*const\s*override\s*\{\s*return true;\s*\}") RE_VERSION = re.compile(r"Version\s*=\s*([0-9]+)-([0-9]+)-([0-9]+)") RE_BUMP_SUGGESTION = re.compile(r"- \*\*(.+?)\.ini\*\*: bump to `([\d-]+)`.*\[link\]\([^)]+\) ?(?:\(([^)]+)\))?") + +# Commit type regexes RE_COMMIT_FEAT = re.compile(r"^feat(\(|:|\s)", re.IGNORECASE) RE_COMMIT_FIX = re.compile(r"^fix(\(|:|\s)", re.IGNORECASE) +RE_COMMIT_REFACTOR = re.compile(r"^refactor(\(|:|\s)", re.IGNORECASE) +RE_COMMIT_PERF = re.compile(r"^perf(\(|:|\s)", re.IGNORECASE) +RE_COMMIT_BREAKING = re.compile(r"!\s*:|BREAKING CHANGE:", re.IGNORECASE) +RE_COMMIT_NONFUNCTIONAL = re.compile(r"^(chore|docs|style|ci|test|build)(\(|:|\s)", re.IGNORECASE) + # ===================== # End Configuration # ===================== @@ -81,7 +88,9 @@ def get_prior_version(ini_path, base_ref): except Exception: return None -def get_changed_files(feature_path, base_ref): +def get_changed_files(feature_path, base_ref, file_types=None): + if file_types is None: + file_types = SHADER_TYPES rel_path = str(feature_path).replace("\\", "/") try: output = subprocess.check_output( @@ -92,7 +101,7 @@ def get_changed_files(feature_path, base_ref): for line in output.splitlines(): status, file = line.split(maxsplit=1) ext = os.path.splitext(file)[1].lower() - if ext in SHADER_TYPES: + if ext in file_types: changes.append((status, file)) return changes except Exception: @@ -104,7 +113,7 @@ def get_commits_for_file(file_path, base_ref): ["git", "log", f"{base_ref}..HEAD", "--pretty=%s", "--", file_path], stderr=subprocess.DEVNULL, ).decode("utf-8") - return [line.strip() for line in output.splitlines()] + return [line.strip() for line in output.splitlines() if line.strip()] except Exception: return [] @@ -115,9 +124,11 @@ def get_bump_commit(file_path, base_ref): stderr=subprocess.DEVNULL, ).decode("utf-8") for line in output.splitlines(): - commit_hash, *msg = line.split(" ", 1) - msg = msg[0] if msg else "" - if RE_COMMIT_FEAT.match(msg) or RE_COMMIT_FIX.match(msg): + parts = line.split(" ", 1) + if len(parts) < 2: + continue + commit_hash, msg = parts + if RE_COMMIT_FEAT.match(msg) or RE_COMMIT_FIX.match(msg) or RE_COMMIT_REFACTOR.match(msg) or RE_COMMIT_PERF.match(msg) or RE_COMMIT_BREAKING.search(msg): return commit_hash except Exception: pass @@ -133,6 +144,22 @@ def get_commit_author(commit_hash): except Exception: return None +def apply_version_bump(ini_path, proposed_ver_str): + try: + with open(ini_path, "r", encoding="utf-8") as f: + content = f.read() + + # Replace Version = X-X-X with Version = proposed_ver_str + new_content = RE_VERSION.sub(f"Version = {proposed_ver_str}", content, count=1) + + if new_content != content: + with open(ini_path, "w", encoding="utf-8") as f: + f.write(new_content) + return True + except Exception as e: + print(f"Error applying bump to {ini_path}: {e}", file=sys.stderr) + return False + def parse_feature_metadata_file(path, mod_id=None, is_core=False): mod_link = "" description = "" @@ -227,73 +254,84 @@ def tag_key(tag): except Exception: return None -def get_changed_files_pr(base_ref=DEFAULT_PR_BASE_REF): - try: - output = subprocess.check_output([ - "git", "diff", "--name-status", f"{base_ref}...HEAD"], stderr=subprocess.DEVNULL - ).decode("utf-8") - changes = [] - for line in output.splitlines(): - status, file = line.split(maxsplit=1) - ext = os.path.splitext(file)[1].lower() - if ext in SHADER_TYPES: - changes.append((status, file)) - return changes - except Exception: - return [] - def detect_pr_base(): - # 1. Use --pr-base if provided (handled by argparse) - # 2. Use GITHUB_BASE_REF if set (GitHub Actions) + # 1. Use GITHUB_BASE_REF if set (GitHub Actions) env_base_ref = os.environ.get("GITHUB_BASE_REF") if env_base_ref: - print(f"Detected PR base from GITHUB_BASE_REF: origin/{env_base_ref}") + print(f"Detected PR base from GITHUB_BASE_REF: origin/{env_base_ref}", file=sys.stderr) return f"origin/{env_base_ref}" - # 3. Fallback - print(f"Falling back to {DEFAULT_PR_BASE_REF} for PR base.") + # 2. Fallback + print(f"Falling back to {DEFAULT_PR_BASE_REF} for PR base.", file=sys.stderr) return DEFAULT_PR_BASE_REF def propose_new_version(prior_version, commits): if not prior_version: return None major, minor, patch = prior_version - # Only bump for feat or fix (not refactor, chore, docs, etc.) - has_feat = any(RE_COMMIT_FEAT.match(c) for c in commits) - has_fix = any(RE_COMMIT_FIX.match(c) for c in commits) - if has_feat: + if not commits: + return None + + is_minor = any(RE_COMMIT_FEAT.match(c) or RE_COMMIT_BREAKING.search(c) for c in commits) + is_patch = any(RE_COMMIT_FIX.match(c) or RE_COMMIT_REFACTOR.match(c) or RE_COMMIT_PERF.match(c) for c in commits) + is_nonfunctional_only = all(RE_COMMIT_NONFUNCTIONAL.match(c) for c in commits) + + if is_minor: return (major, minor + 1, 0) - elif has_fix: + elif is_patch: return (major, minor, patch + 1) + elif is_nonfunctional_only: + return None else: - return prior_version + return (major, minor, patch + 1) def analyze_features(FEATURES_DIR, feature_meta_map, base_ref, only_changed=False): bump_suggestions = [] new_features = [] actionable = False - author_stats = {} feature_actions = {} feature_analysis = [] # If only_changed, build a set of changed feature names changed_features = set() if only_changed: - # Gather all changed files from the diff - all_changes = subprocess.check_output([ - "git", "diff", "--name-status", f"{base_ref}...HEAD", "--", str(FEATURES_DIR) - ], stderr=subprocess.DEVNULL).decode("utf-8").splitlines() + # Gather all changed files from the diff in both features regions + target_dirs = [str(FEATURES_DIR), str(DEFAULT_FEATURE_HEADERS_DIR)] + cmd = ["git", "diff", "--name-status", f"{base_ref}...HEAD", "--"] + target_dirs + try: + all_changes = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode("utf-8").splitlines() + except Exception as e: + print(f"Error running git diff: {e}", file=sys.stderr) + all_changes = [] + for line in all_changes: parts = line.split(maxsplit=1) if len(parts) != 2: continue status, file = parts file_parts = Path(file).parts - # Find the feature directory name - try: - idx = file_parts.index(FEATURES_DIR.name) - feature_name = file_parts[idx+1] - changed_features.add(feature_name) - except Exception: - continue + + # Case 1: features/[Feature Name]/... + if FEATURES_DIR.name in file_parts: + try: + idx = file_parts.index(FEATURES_DIR.name) + feature_name = file_parts[idx+1] + changed_features.add(feature_name) + continue + except (ValueError, IndexError): + pass + + # Case 2: src/Features/[Feature Name].cpp or src/Features/[Feature Name]/... + if "src" in file_parts and "Features" in file_parts: + try: + idx = file_parts.index("Features") + name_part = file_parts[idx+1] + # If it's a file, strip extension. If directory, it's the feature name. + feature_name = os.path.splitext(name_part)[0] + changed_features.add(feature_name) + # We also add the normalized version to be safe + changed_features.add(''.join(feature_name.lower().split())) + except (ValueError, IndexError): + pass + # Always use GetShortName() for feature key normalization if available def get_feature_key(feature_dir, feature_meta_map): # Try to use GetShortName from metadata if present @@ -302,20 +340,37 @@ def get_feature_key(feature_dir, feature_meta_map): return ''.join(meta['short_name'].lower().split()) # Fallback to directory name return ''.join(feature_dir.name.lower().split()) + for feature_dir in FEATURES_DIR.iterdir(): if not feature_dir.is_dir(): continue feature_key = get_feature_key(feature_dir, feature_meta_map) - if only_changed and feature_dir.name not in changed_features: + + # membership check + normalized_name = ''.join(feature_dir.name.lower().split()) + if only_changed and feature_dir.name not in changed_features and feature_key not in changed_features and normalized_name not in changed_features: continue + meta = feature_meta_map.get(feature_key) - is_core = meta['is_core'] if meta else False - if is_core: - continue ini_path = get_feature_ini(feature_dir) prior_ver = get_prior_version(ini_path, base_ref) if ini_path else None new_ver = get_version_from_ini(ini_path) if ini_path else None + changes = get_changed_files(feature_dir, base_ref) + # Also check src/Features + if meta: + header_path = DEFAULT_FEATURE_HEADERS_DIR / (meta['name'] + ".h") + cpp_path = DEFAULT_FEATURE_HEADERS_DIR / (meta['name'] + ".cpp") + feature_src_dir = DEFAULT_FEATURE_HEADERS_DIR / meta['name'] + cpp_types = (".h", ".hpp", ".cpp", ".c") + if header_path.exists(): + changes.extend(get_changed_files(header_path, base_ref, file_types=cpp_types)) + if cpp_path.exists(): + changes.extend(get_changed_files(cpp_path, base_ref, file_types=cpp_types)) + if feature_src_dir.exists() and feature_src_dir.is_dir(): + changes.extend(get_changed_files(feature_src_dir, base_ref, file_types=cpp_types)) + changes = list(set(changes)) + change_types = set(os.path.splitext(f)[1].lower() for _, f in changes) all_commits = [] bump_commit = None @@ -327,6 +382,7 @@ def get_feature_key(feature_dir, feature_meta_map): bump_commit = get_bump_commit(f, base_ref) if bump_commit: bump_author = get_commit_author(bump_commit) + proposed_ver = propose_new_version(prior_ver, all_commits) if ini_path else None needs_bump = (proposed_ver is not None and new_ver is not None and proposed_ver > new_ver) proposed_ver_str = "-".join(map(str, proposed_ver)) if proposed_ver else "-" @@ -334,6 +390,7 @@ def get_feature_key(feature_dir, feature_meta_map): new_ver_str = "-".join(map(str, new_ver)) if new_ver else "-" note = "" is_attention = False + # Detect new feature (all files added, ini present) if changes and all(s == "A" for s, _ in changes): if ini_path: @@ -354,24 +411,20 @@ def get_feature_key(feature_dir, feature_meta_map): note = "Files added, ini missing!" new_features.append((feature_dir.name, "-", bump_commit)) is_attention = True + if needs_bump: is_attention = True if is_attention: actionable = True - # Track author stats for actionable items - if is_attention and bump_author: - if bump_author not in author_stats: - author_stats[bump_author] = {'new': 0, 'bump': 0} - if (changes and all(s == "A" for s, _ in changes) and ini_path) or (ini_path and prior_ver is None and new_ver is not None): - author_stats[bump_author]['new'] += 1 - elif needs_bump: - author_stats[bump_author]['bump'] += 1 + commit_link = "" if bump_commit: author_str = f" ({bump_author})" if bump_author else "" commit_link = f"[link](https://github.com/doodlum/skyrim-community-shaders/commit/{bump_commit}){author_str}" + def bold(val): return f"**{val}**" if is_attention and val != '' and val != '-' else val + feature_analysis.append({ 'name': feature_dir.name, 'prior_ver_str': prior_ver_str, @@ -380,16 +433,19 @@ def bold(val): 'change_types': ', '.join(sorted(change_types)), 'note': note, 'commit_link': commit_link, - 'is_attention': is_attention + 'is_attention': is_attention, + 'ini_path': str(ini_path) if ini_path else None }) if needs_bump: bump_suggestions.append(f"- **{os.path.basename(ini_path)}**: bump to `{proposed_ver_str}` {commit_link}") - # Build actionable suggestions if is_attention: - feature_actions.setdefault(feature_dir.name, []).append(note or "Action required") + feat_act = feature_actions.setdefault(feature_dir.name, {"actions": [], "author": bump_author}) + if note: + feat_act["actions"].append(note) if needs_bump: - feature_actions[feature_dir.name].append(f"Needs version bump to {proposed_ver_str}") - return feature_analysis, bump_suggestions, new_features, actionable, author_stats, feature_actions + feat_act["actions"].append(f"Needs version bump to {proposed_ver_str}") + + return feature_analysis, bump_suggestions, new_features, actionable, feature_actions def print_actionable_suggestions(feature_actions): if feature_actions: @@ -485,7 +541,6 @@ def boldmeta(val, missing=missing): def build_feature_actions(bump_suggestions, metadata_issues, new_features, get_commit_author, normalize_name): feature_actions = {} - # Add bump suggestions for suggestion in bump_suggestions: m = RE_BUMP_SUGGESTION.match(suggestion) if m: @@ -493,12 +548,11 @@ def build_feature_actions(bump_suggestions, metadata_issues, new_features, get_c if fname not in feature_actions: feature_actions[fname] = {"actions": [], "author": author} feature_actions[fname]["actions"].append(f"Bump INI version to `{ver}`") - # Add metadata issues for name, fields in metadata_issues: if name not in feature_actions: feature_actions[name] = {"actions": [], "author": None} feature_actions[name]["actions"].append(f"Add: {', '.join(fields)}") - # Always try to link author from new_features for any feature (normalize names) + # Mapping new features to authors new_features_map = {normalize_name(n): (commit, get_commit_author(commit) if commit else None) for n, _, commit in new_features} for name in feature_actions: if not feature_actions[name]["author"]: @@ -545,18 +599,12 @@ def generate_audit_report( if base_date_iso and base_date_human: lines.append(f"_Base commit date:_ `{base_date_iso}` ({base_date_human}) ") lines.append(f"_Generated:_ `{now}`\n") - lines.append("") - # Feature table lines.extend(format_feature_table(feature_analysis)) - # Critical Information Summary lines.append("\n## Critical Information Summary\n") lines.extend(format_new_features_table(new_features, feature_meta_map, get_commit_author, normalize_name)) - # Feature metadata summary metadata_lines, metadata_issues = format_metadata_summary(feature_metadata) lines.extend(metadata_lines) - # Build unified actionable suggestions per feature feature_actions = build_feature_actions(bump_suggestions, metadata_issues, new_features, get_commit_author, normalize_name) - # Build author stats directly from actionable suggestions author_stats = build_author_stats(feature_actions) author_stats_lines = format_author_stats(author_stats) actionable_lines = format_actionable_lines(feature_actions) @@ -566,29 +614,25 @@ def generate_audit_report( return output def main(): - global RELEASE_TAG, FEATURES_DIR, SHADER_TYPES parser = argparse.ArgumentParser(description="Feature version audit for Skyrim Community Shaders.") parser.add_argument('--output', type=str, help='Output markdown filename') - parser.add_argument('--ci', action='store_true', help='Exit 1 if actionable items found') - parser.add_argument('--base', type=str, default=None, help='Base tag/branch/commit to compare against (default: latest tag)') - parser.add_argument('--fail-on-actionable', action='store_true', help='Fail if any actionable tasks (besides version bumps) are present') - parser.add_argument('--pr-check', action='store_true', help='Only show actionable items for features/files changed since base') + parser.add_argument('--ci', action='store_true', help='Exit 1 if actionable items found (alias for --fail-on-actionable)') + parser.add_argument('--base', type=str, default=None, help='Base tag/branch/commit to compare against') + parser.add_argument('--fail-on-actionable', action='store_true', help='Exit 1 if actionable items found (alias for --ci)') + parser.add_argument('--pr-check', action='store_true', help='Only show actionable items for changes since base') + parser.add_argument('--apply-bumps', action='store_true', help='Automatically apply suggested version bumps') args = parser.parse_args() - # Determine base ref if args.base: base_ref = args.base - print(f"Using provided base ref: {base_ref}") else: - detected = get_latest_release_tag() - if detected: - base_ref = detected - print(f"Detected latest release tag: {base_ref}") + detected_base = detect_pr_base() if args.pr_check else get_latest_release_tag() + if detected_base: + base_ref = detected_base else: - print("No valid release tag found. Exiting.") + print("No valid base ref found.", file=sys.stderr) sys.exit(1) - # Get base commit date for human-readable output base_date_iso = None base_date_human = None try: @@ -597,88 +641,72 @@ def main(): ], stderr=subprocess.DEVNULL).decode("utf-8").strip() base_date_human = datetime.datetime.fromisoformat(base_date_iso.replace('Z', '+00:00')).strftime('%A, %B %d, %Y %I:%M %p') except Exception: - base_date_iso = None - base_date_human = None + pass - # Print base ref and date for both audit and pr-check modes - print(f"Using base ref: {base_ref}") - if base_date_iso and base_date_human: - print(f"Base commit date: {base_date_iso} ({base_date_human})") + print(f"Using base ref: {base_ref}", file=sys.stderr) + if base_date_iso: + print(f"Base commit date: {base_date_iso} ({base_date_human})", file=sys.stderr) - # Parse all feature metadata up front and build a normalized lookup feature_metadata = extract_feature_metadata(DEFAULT_FEATURE_HEADERS_DIR) - def normalize_name(name): - return ''.join(name.lower().split()) + def normalize_name(name): return ''.join(name.lower().split()) feature_meta_map = {normalize_name(f['name']): f for f in feature_metadata} - feature_analysis, bump_suggestions, new_features, actionable, author_stats, feature_actions = analyze_features( + feature_analysis, bump_suggestions, new_features, actionable, feature_actions = analyze_features( FEATURES_DIR, feature_meta_map, base_ref, only_changed=args.pr_check) now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') date_tag = datetime.datetime.now().strftime('%Y-%m-%d') - # Determine output file: default only if not pr-check - output_file = None - if not getattr(args, 'pr_check', False): - output_file = args.output or f"feature-version-audit-{date_tag}.md" - elif args.output: - output_file = args.output - - if getattr(args, 'pr_check', False): - # Ensure feature_actions is always a dict with 'actions' and 'author' keys - # If it's a dict of lists (legacy), convert to new format - for fname, val in list(feature_actions.items()): - if isinstance(val, list): - feature_actions[fname] = {"actions": val, "author": None} - # Only print actionable suggestions for PR check (omit author stats) + output_file = args.output if args.output else (None if args.pr_check else f"feature-version-audit-{date_tag}.md") + + if args.apply_bumps: + applied_count = 0 + for fa in feature_analysis: + if fa['needs_bump'] and fa['ini_path']: + if apply_version_bump(fa['ini_path'], fa['proposed_ver_str']): + print(f"Applied bump to {fa['name']}: {fa['prior_ver_str']} -> {fa['proposed_ver_str']}", file=sys.stderr) + applied_count += 1 + + fa['needs_bump'] = False + + print(f"\nSuccessfully applied {applied_count} version bumps." if applied_count > 0 else "\nNo version bumps applied.", file=sys.stderr) + + # Remove stale bump actions from feature_actions + for fname in list(feature_actions.keys()): + info = feature_actions[fname] + info["actions"] = [a for a in info["actions"] if not a.startswith("Needs version bump")] + if not info["actions"]: + del feature_actions[fname] + + # Filter bump_suggestions for the full-report path + bumped_ini_names = { + os.path.basename(fa['ini_path']) + for fa in feature_analysis + if not fa['needs_bump'] and fa.get('ini_path') + } + bump_suggestions = [ + s for s in bump_suggestions + if not any(f"**{name}**" in s for name in bumped_ini_names) + ] + + # Recompute actionable after applying bumps + actionable = any(fa.get('needs_bump') or "missing" in fa.get('note', '').lower() for fa in feature_analysis) + if new_features: + actionable = True + + if args.pr_check: print_actionable_suggestions(feature_actions) else: - output = generate_audit_report( - base_ref, - base_date_iso, - base_date_human, - now, - feature_analysis, - new_features, - feature_meta_map, - get_commit_author, - normalize_name, - feature_metadata, - bump_suggestions - ) + output = generate_audit_report(base_ref, base_date_iso, base_date_human, now, + feature_analysis, new_features, feature_meta_map, + get_commit_author, normalize_name, feature_metadata, bump_suggestions) if output_file: - with open(output_file, "w", encoding="utf-8") as f: - f.write(output) + with open(output_file, "w", encoding="utf-8") as f: f.write(output) else: print(output) - if actionable: - if args.output or args.ci or args.fail_on_actionable: - # Only print actionable items if not in audit/report mode (i.e., no --output) - if not args.output: - print("::error:: There are actionable tasks (besides version bumps) that should be completed before merging.") - print("Actionable items detected:") - for fname, info in feature_actions.items(): - author = f" ({info['author']})" if info.get('author') else "" - print(f"- {fname}{author}: " + "; ".join(info["actions"])) - if args.fail_on_actionable: - sys.exit(1) - if args.ci and actionable: + + if actionable and (args.ci or args.fail_on_actionable): sys.exit(1) sys.exit(0) if __name__ == "__main__": main() - -# -# Usage in workflow (for documentation): -# -# For PR checks (auto-detects base): -# python tools/feature_version_audit.py --pr-check -# For PR checks (explicit base): -# python tools/feature_version_audit.py --pr-check --base origin/${{ github.event.pull_request.base.ref }} -# For tag/release (generates markdown report): -# python tools/feature_version_audit.py --output feature-version-audit-latest.md -# For CI strict mode (fail on any actionable): -# python tools/feature_version_audit.py --pr-check --fail-on-actionable -# To specify a custom base tag/branch: -# python tools/feature_version_audit.py --base v1.2.1 -#