Viewing: changed.py

#!/usr/bin/env python3

#
# logic snipped from analyze_patch() here:
# https://github.com/verygreen/lustretester/blob/master/gerrit_build-and-test-new.py
#

import json
import os
import re
import sys

if len(sys.argv[1:]) != 1:
    print("script takes 1 argument; path to output of git format-patch for the patch to analyze")
    sys.exit(1)

# script takes 1 argument; the path to the output of git format-patch
file_path = sys.argv[1:][0]

# verify the path exists
if not os.path.exists(file_path):
    print("'%s' does not exist" % file_path)
    sys.exit(1)


def affected_files(patch_content):
    """
    Parse a git patch content and categorize file changes.

    Args:
        patch_content (str): Content of the git patch

    Returns:
        dict: Dictionary with lists of modified, added, and deleted files
    """
    changes = { 'm': [ ], 'a': [ ], 'd': [ ] }
    current_file = None

    for line in patch_content.splitlines():
        if line.startswith('diff --git'):
            # Extract filename from: diff --git a/path/to/file b/path/to/file
            parts = line.split()
            if len(parts) >= 4:
                current_file = parts[2][2:]  # Remove 'a/' prefix
                if current_file not in [f for files in changes.values() for f in files]:
                    changes['m'].append(current_file)
        elif line.startswith('new file mode'):
            if changes['m'] and current_file:
                changes['m'].pop()  # Remove from modified if added
            if current_file:
                changes['a'].append(current_file)
        elif line.startswith('deleted file mode'):
            if changes['m'] and current_file:
                changes['m'].pop()  # Remove from modified if deleted
            if current_file:
                changes['d'].append(current_file)

    return changes

# read the file in
with open(file_path, 'r') as file:
    # Read the entire file into a string
    patch = file.read()

change = {}
chfile = None
function = None
newtests = [] # captures newly created tests
for line in patch.splitlines():
    if line.startswith('+++ '):
        if newtests:
            if not change.get('updated_tests'):
                change['updated_tests'] = {}
            if basename.endswith('.sh') or basename == 'runtests':
                change['updated_tests'].update({basename.replace('.sh', ''):newtests})
            newtests = []
        chfile = line.replace('+++ b/', '')
        basename = os.path.basename(chfile)
    if not chfile: # diff did not start yet - skip
        continue
    if line.startswith('--- '): # src file - skip
        continue
    if line.startswith('@@ '):
        tags = line.split(' ', 5)
        if tags[0] != '@@' or tags[3] != '@@':
            print("Malformed patch line: " + line)
            continue
        if len(tags) > 4:
            function = tags[4].replace('()', '')
            if function.endswith("{"):
                function = function[:-1]
        else:
            function = None
    if line.startswith(' '): # context line, not a change - skip
        # context changed to new function, record it.
        if (basename.endswith(".sh") or basename == 'runtests') and line.startswith(' test_'):
            tags = line[1:].split(' ')
            function = tags[0].replace('()', '')
            if function.endswith("{"):
                function = function[:-1]
        continue
    if re.match(r"^[-+][^\-+]", line): # added/removed/changed line
        tmp = line.replace(' ', '').replace('\t', '') # remove spaces
        if not line[1:]: # empty line? skip
            continue
        if tmp[1:].startswith('#'): # comment, skip
            continue
        if line[1:].startswith('test_'):
            function = None # Added or removed function, we'll catch with the +run_test
        if function and function not in newtests:
            if (basename.endswith('.sh') or basename == 'runtests') and function.startswith("test_"):
                newtests.append(function)
            function = None # To ease our work
    if re.match(r"^[+][^+]", line): # Added/changed line
        if basename.endswith('.sh') or basename == 'runtests':
            # Try to detect a new test added.
            # while we can try and detect new function added, instead
            # in our framework there's a very specific pattern:
            # +run_test 65 "Check lfs quota result"
            # So let's match for that instead
            if line.startswith("+run_test "):
                tags = line.split(" ")
                if len(tags) > 1:
                    test = "test_" + tags[1]
                    newtests.append(test)
# Catch remaining stuff
if newtests and (basename.endswith('.sh') or basename == 'runtests'):
    if not change.get('updated_tests'):
        change['updated_tests'] = {}
    change['updated_tests'].update({basename.replace('.sh', ''):newtests})

# Check if parallel-scale-nfs exists in updated_tests and handle it specially
if 'updated_tests' in change and 'parallel-scale-nfs' in change['updated_tests']:
    # Get the tests associated with parallel-scale-nfs
    nfs_tests = change['updated_tests']['parallel-scale-nfs']

    # Define target keys
    target_keys = ['parallel-scale-nfsv3', 'parallel-scale-nfsv4']

    # Process each target key
    for target_key in target_keys:
        # Simply assign the tests to each target key
        # Use list() to create a copy of the list since Python 2 doesn't have list.copy()
        change['updated_tests'][target_key] = list(nfs_tests)

    # Remove the original entry
    del change['updated_tests']['parallel-scale-nfs']

# Add affected files information
change['affected_files']  = affected_files(patch)

# Check if patch only changes paths that don't require testing
# Paths that don't require testing: Documentation/*, LICENSES/*, lustre/ChangeLog, contrib/*
def only_non_testable_paths(affected):
    """
    Check if all affected files match paths that don't require testing.

    Args:
        affected (dict): Dictionary with 'm', 'a', 'd' keys containing file lists

    Returns:
        bool: True if all files are in non-testable paths, False otherwise
    """
    non_testable_prefixes = [ 'Documentation/', 'LICENSES/', 'contrib/' ]
    non_testable_exact = [ 'lustre/ChangeLog' ]

    # Collect all affected files from all categories
    all_files = affected.get('m', [ ]) + affected.get('a', [ ]) + affected.get('d', [ ])

    # If no files were changed, we can't skip testing
    if not all_files:
        return False

    # Check each file against the non-testable patterns
    for filepath in all_files:
        is_non_testable = False

        # Check against exact matches
        if filepath in non_testable_exact:
            is_non_testable = True

        # Check against prefix matches
        for prefix in non_testable_prefixes:
            if filepath.startswith(prefix):
                is_non_testable = True
                break

        # If any file is testable, return False
        if not is_non_testable:
            return False

    # All files matched non-testable patterns
    return True

if only_non_testable_paths(change['affected_files']):
    change['SkipTesting'] = True
    if 'updated_tests' in change:
        del change['updated_tests']

print(json.dumps(change))