diff --git a/check_kernel_commits.py b/check_kernel_commits.py index 8aca3fd..6199b7a 100644 --- a/check_kernel_commits.py +++ b/check_kernel_commits.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import argparse -import re import subprocess import sys import textwrap @@ -15,6 +14,7 @@ CIQ_run_git, CIQ_setup_vulns_repo, ) +from kt.ktlib.commit_header import CommitHeader def ref_exists(repo, ref): @@ -59,15 +59,6 @@ def wrap_paragraph(text, width=80, initial_indent="", subsequent_indent=""): return wrapper.fill(text) -def extract_cve_from_message(msg): - """Extract CVE reference from commit message. Returns CVE ID or None. - Only matches 'cve CVE-2025-12345', ignores 'cve-bf' and 'cve-pre' variants.""" - match = re.search(r"(? in upstream_ref fixes = CIQ_find_fixes_in_mainline(repo=args.repo, upstream_ref=upstream_ref, hash_=uhash) @@ -267,7 +261,7 @@ def main(): # Check CVE if enabled if args.check_cves: - cve_id = extract_cve_from_message(msg) + cve_id = commit_header.cve # Check if the upstream commit has a CVE associated with it try: diff --git a/ciq-cherry-pick.py b/ciq-cherry-pick.py index fa92364..acd9564 100644 --- a/ciq-cherry-pick.py +++ b/ciq-cherry-pick.py @@ -1,11 +1,12 @@ import argparse +import copy import logging import os -import re import subprocess import sys import traceback from datetime import datetime +from pathlib import Path import git @@ -16,12 +17,14 @@ CIQ_find_matching_cve, CIQ_fixes_references, CIQ_get_full_hash, - CIQ_original_commit_author_to_tag_string, + CIQ_original_commit_author, CIQ_raise_or_warn, CIQ_reset_HEAD, CIQ_run_git, CIQ_setup_vulns_repo, ) +from kt.ktlib.commit_header import CommitHeader +from kt.ktlib.git_remote import UpstreamRemotes from kt.ktlib.jira import JiraInstance MERGE_MSG = git.Repo(os.getcwd()).git_dir + "/MERGE_MSG" @@ -32,303 +35,334 @@ class CherryPickException(Exception): pass -def extract_cve_from_tag(tag): - """ - Extract CVE ID from matches like 'cve CVE-2026-1234' or 'cve-bf CVE-2026-1234' - Return None if no match - """ - match = re.search(r"(? CommitHeader: + if not commit_header.is_cve(): + return commit_header - # Temporary check until I figure out if we really need multiple tags - if len(ciq_tags) != 1: - print(f"len(ciq tags) != 1, not sure what to do: {ciq_tags}") - return ciq_tags, jira_ticket + origin_cve_number = commit_header.cve_number() - if "cve" not in ciq_tags[0]: - # Not a cve, not interested - return ciq_tags, jira_ticket + vuln_cve_number = CIQ_find_matching_cve( + vulns_repo=self._vulns_dir, kernel_repo=os.getcwd(), hash_=commit_header.commit + ) + if not vuln_cve_number: + # Continue with the existing tag + return commit_header + + commit_header.set_cve(cve_number=vuln_cve_number) + if not self._jira_instance or not commit_header.jira: + return commit_header + + # Find the jira ticket corresponding to the CVE if it does not matches the original + if vuln_cve_number != origin_cve_number: + kernel = self._find_lts_kernel(jira_ticket=commit_header.jira) + jira_query = f'''"sRPM[Short text]" ~ "kernel" and "LTS Product[Dropdown]" = "{kernel}" and "CVE ID[Short text]" = "{vuln_cve_number}"''' + issues, _ = self._jira_instance.search_issues(jql=jira_query) + if issues: + new_jira = issues[0].key + commit_header.jira = new_jira + else: + print( + f"[WARNING] Could not find ticket for {vuln_cve_number} for {kernel}, using old ticket {commit_header.jira}" + ) + + return commit_header + + def _update_jira_success(self, ticket_key: str): + if ticket_key is None: + return + + if self._jira_dry_run: + print(f"[DRY-RUN] Would assign {ticket_key}") + print(f"[DRY-RUN] Would transition {ticket_key} to In Progress") + print(f"[DRY-RUN] Would add a label: automated-patch-applied to {ticket_key}") + print( + f"[DRY-RUN] Would add worklog: 30m time spent with comment 'CVE automation: Applied upstream patch' to {ticket_key}" + ) + + return + + if self._jira_instance is None: + return + + self._jira_instance.assign_ticket(issue_key=ticket_key) + self._jira_instance.transition_issue(issue_key=ticket_key, transition_name="In Progress") + self._jira_instance.update_labels(issue_key=ticket_key, labels=["automated-patch-applied"]) + self._jira_instance.add_worklog( + issue_key=ticket_key, + time_spent="30m", + comment="CVE automation: Applied upstream patch", + started=datetime.now(), + ) - matching_cve = CIQ_find_matching_cve(vulns_repo=vulns_repo, kernel_repo=os.getcwd(), hash_=sha) - if not matching_cve: - return ciq_tags, jira_ticket + def _update_jira_failure(self, ticket_key: str): + if ticket_key is None: + return - new_ciq_tags = [f"cve {matching_cve}"] - new_jira = jira_ticket + if self._jira_dry_run: + print(f"[DRY-RUN] Would add a label: automated-patch-failed to {ticket_key}") + return - print(f"CVE {matching_cve} for hash {sha} and original tags {ciq_tags}") + if self._jira_instance is None: + return - if not jira_instance or not jira_ticket: - return new_ciq_tags, new_jira + self._jira_instance.update_labels(issue_key=ticket_key, labels=["automated-patch-failed"]) - # Find the jira ticket corresponding to the CVE only if jira_instance - kernel = find_lts_kernel(jira_instance=jira_instance, jira_ticket=jira_ticket) - origin_cve = extract_cve_from_tag(ciq_tags[0]) - if matching_cve != origin_cve: - jira_query = f'''"sRPM[Short text]" ~ "kernel" and "LTS Product[Dropdown]" = "{kernel}" and "CVE ID[Short text]" = "{matching_cve}"''' - issues, _ = jira_instance.search_issues(jql=jira_query) - if issues: - new_jira = issues[0].key - else: - print(f"[WARNING] Could not find ticket for {matching_cve} for {kernel}, using old ticket {jira_ticket}") + def _check_fixes(self, sha): + """ + Checks if commit has "Fixes:" references and if so, it checks if the + commit(s) that it tries to fix are part of the current branch + """ - return new_ciq_tags, new_jira + fixes = CIQ_fixes_references(repo_path=os.getcwd(), sha=sha) + if len(fixes) == 0: + logging.warning("The commit you try to cherry pick has no Fixes: reference; review it carefully") + return + not_present_fixes = [] + for fix in fixes: + if not CIQ_commit_exists_in_current_branch(os.getcwd(), fix): + not_present_fixes.append(fix) -def update_jira_success(jira_instance, ticket_key, jira_dry_run): - if ticket_key is None: - return + err = f"The commit you want to cherry pick has the following Fixes: references that are not part of the tree {not_present_fixes}" + CIQ_raise_or_warn(cond=not_present_fixes, error_msg=err, warn=self._ignore_fixes_check) - if jira_dry_run: - print(f"[DRY-RUN] Would assign {ticket_key}") - print(f"[DRY-RUN] Would transition {ticket_key} to In Progress") - print(f"[DRY-RUN] Would add a label: automated-patch-applied to {ticket_key}") - print( - f"[DRY-RUN] Would add worklog: 30m time spent with comment 'CVE automation: Applied upstream patch' to {ticket_key}" - ) + def _manage_commit_message(self, commit_header: CommitHeader, commit_successful: bool): + """ + Standardize the commit message by including the ciq_tags, original + author and the original commit full sha. - return + Original message location: MERGE_MSG + Makes a copy of the original message in MERGE_MSG_BAK - if jira_instance is None: - return + The new standardized commit message is written to MERGE_MSG + """ - jira_instance.assign_ticket(issue_key=ticket_key) - jira_instance.transition_issue(issue_key=ticket_key, transition_name="In Progress") - jira_instance.update_labels(issue_key=ticket_key, labels=["automated-patch-applied"]) - jira_instance.add_worklog( - issue_key=ticket_key, - time_spent="30m", - comment="CVE automation: Applied upstream patch", - started=datetime.now(), - ) + subprocess.run(["cp", MERGE_MSG, MERGE_MSG_BAK], check=True) + try: + with open(MERGE_MSG, "r") as file: + original_msg = file.readlines() + except IOError as e: + raise RuntimeError(f"Failed to read commit message from {MERGE_MSG}: {e}") from e -def update_jira_failure(jira_instance, ticket_key, jira_dry_run): - if ticket_key is None: - return - - if jira_dry_run: - print(f"[DRY-RUN] Would add a label: automated-patch-failed to {ticket_key}") - return - - if jira_instance is None: - return - - jira_instance.update_labels(issue_key=ticket_key, labels=["automated-patch-failed"]) + # git appends a "# Conflicts:" comment block to MERGE_MSG on a conflicted + # cherry-pick, and committing with -F doesn't strip comment lines. Drop the + # block to keep it out of the final commit message. + for i, line in enumerate(original_msg): + if line.rstrip("\n") == "# Conflicts:": + original_msg = original_msg[:i] + break + if not commit_successful: + commit_header.upstream_diff = "|" -def check_fixes(sha, ignore_fixes_check): - """ - Checks if commit has "Fixes:" references and if so, it checks if the - commit(s) that it tries to fix are part of the current branch - """ - - fixes = CIQ_fixes_references(repo_path=os.getcwd(), sha=sha) - if len(fixes) == 0: - logging.warning("The commit you try to cherry pick has no Fixes: reference; review it carefully") - return + new_msg = CIQ_cherry_pick_commit_standardization(lines=original_msg, commit_header=commit_header) - not_present_fixes = [] - for fix in fixes: - if not CIQ_commit_exists_in_current_branch(os.getcwd(), fix): - not_present_fixes.append(fix) - - err = f"The commit you want to cherry pick has the following Fixes: references that are not part of the tree {not_present_fixes}" - CIQ_raise_or_warn(cond=not_present_fixes, error_msg=err, warn=ignore_fixes_check) + print(f"\n Original Message located here: {MERGE_MSG_BAK}") + try: + with open(MERGE_MSG, "w") as file: + file.writelines(new_msg) + except IOError as e: + raise RuntimeError(f"Failed to write commit message to {MERGE_MSG}: {e}") from e + + def full_cherry_pick(self, commit_header: CommitHeader): + """ + Cherry picks a commit from upstream-ref along with its Fixes: references. + If cherry-pick or cherry_pick_fixes fail, the exception is propagated + If one of the cherry picks fails, an exception is returned and the previous + successful cherry picks are left as they are. + """ + + updated_commit_header = self._adapt_cve_number_with_jira(commit_header=commit_header) + + # Cherry pick the commit + try: + self._cherry_pick(commit_header=updated_commit_header) + except (CherryPickException, RuntimeError) as e: + self._update_jira_failure(ticket_key=updated_commit_header.jira) + raise e -def manage_commit_message(full_sha, ciq_tags, jira_ticket, commit_successful): - """ - Standardize the commit message by including the ciq_tags, original - author and the original commit full sha. + # Cherry pick the fixed-by dependencies + try: + self._cherry_pick_fixes(original_commit_header=updated_commit_header) + except (CherryPickException, RuntimeError) as e: + # TODO would add some extra information to jira if the cve-bf deps are not applied + raise e + + # Update jira only if its deps are applied as well + self._update_jira_success(ticket_key=updated_commit_header.jira) + + def _cherry_pick(self, commit_header: CommitHeader): + """ + Cherry picks a commit and it adds the ciq standardized format + In case of error (cherry pick conflict): + - MERGE_MSG.bak contains the original commit message + - MERGE_MSG contains the standardized commit message + - Conflict has to be solved manually + In case of runtime errors that are not cherry pick conflicts, the cherry + pick changes are reverted. (git reset --hard HEAD) + + In case of success: + - the commit is cherry picked + - MERGE_MSG.bak is deleted + - You can still see MERGE_MSG for the original message + """ + + _, upstream_hash = commit_header.extract_upstream_name_and_sha() + self._check_fixes(sha=upstream_hash) + + # Commit message is in MERGE_MSG + commit_successful = True + try: + print(f"Cherry Pick New Message for {upstream_hash}") + CIQ_run_git(repo_path=os.getcwd(), args=["cherry-pick", "-nsx", upstream_hash]) + except RuntimeError: + commit_successful = False - Original message location: MERGE_MSG - Makes a copy of the original message in MERGE_MSG_BAK + author = CIQ_original_commit_author(repo_path=os.getcwd(), sha=upstream_hash) + if author is None: + raise RuntimeError(f"Could not find author of commit {commit_header.commit}") + commit_header.commit_author = author - The new standardized commit message is written to MERGE_MSG + try: + self._manage_commit_message(commit_header=commit_header, commit_successful=commit_successful) + except RuntimeError as e: + CIQ_reset_HEAD(repo=os.getcwd()) + raise RuntimeError(f"Could not create proper commit message: {e}") from e + + if not commit_successful: + error_str = ( + f"[FAILED] git cherry-pick -nsx {upstream_hash}\n" + "Manually resolve conflict and add explanation under `upstream-diff` tag in commit message\n" + ) + raise CherryPickException(error_str) + + CIQ_run_git(repo_path=os.getcwd(), args=["commit", "-F", MERGE_MSG]) + + def _cherry_pick_fixes(self, original_commit_header: CommitHeader): + """ + Check upstream_ref for commits that have this reference: + Fixes: . If any, these will also be cherry picked with the ciq + tag = cve-bf. If the tag was cve-pre, it stays the same. + """ + # Fixes: follow-ups are searched in mainline. Without a mainline sha + # (source-only pick, e.g. from centos-stream-9 or lore) there is + # nothing to search for. + mainline_sha = original_commit_header.commit + if not mainline_sha or mainline_sha == "-": + print("[NOTE]: No mainline commit for this pick, skipping Fixes: check") + return + + fixes_in_mainline = CIQ_find_fixes_in_mainline_current_branch_unapplied( + repo=os.getcwd(), + upstream_ref=self._upstream_ref, + hash_=mainline_sha, # TODO this original_commit_header stuff + ) + # TODO print all fixes I think + for full_hash, display_str in fixes_in_mainline: + print(f"Extra cherry picking {full_hash}: {display_str}") + bf_commit_header = copy.deepcopy(original_commit_header) + bf_commit_header.make_it_cve_bf() + bf_commit_header.commit = full_hash + # TODO delete the sha / commit stuff + self.full_cherry_pick(commit_header=bf_commit_header) + + +def resolve_sha_alias(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None: + """Fold the deprecated --sha flag into --commit. + + Both name the same internal parameter. Passing both with conflicting + values is an error; otherwise --sha (when given) wins over an unset + --commit. Must be called right after parse_args(), before anything + reads args.commit. """ + sha = getattr(args, "sha", None) + if sha is None: + return - subprocess.run(["cp", MERGE_MSG, MERGE_MSG_BAK], check=True) - - # Make sure it's a deep copy because ciq_tags may be used for other cherry-picks - new_tags = [tag for tag in ciq_tags] - - author = CIQ_original_commit_author_to_tag_string(repo_path=os.getcwd(), sha=full_sha) - if author is None: - raise RuntimeError(f"Could not find author of commit {full_sha}") - - new_tags.append(author) - try: - with open(MERGE_MSG, "r") as file: - original_msg = file.readlines() - except IOError as e: - raise RuntimeError(f"Failed to read commit message from {MERGE_MSG}: {e}") from e - - # git appends a "# Conflicts:" comment block to MERGE_MSG on a conflicted - # cherry-pick, and committing with -F doesn't strip comment lines. Drop the - # block to keep it out of the final commit message. - for i, line in enumerate(original_msg): - if line.rstrip("\n") == "# Conflicts:": - original_msg = original_msg[:i] - break - - optional_msg = "" if commit_successful else "upstream-diff |" - new_msg = CIQ_cherry_pick_commit_standardization( - original_msg, full_sha, jira=jira_ticket, tags=new_tags, optional_msg=optional_msg - ) + if args.commit and args.commit != sha: + parser.error("--commit and --sha are aliases; specify only one of them") - print(f"Cherry Pick New Message for {full_sha}") - print(f"\n Original Message located here: {MERGE_MSG_BAK}") + args.commit = sha + args.sha = None - try: - with open(MERGE_MSG, "w") as file: - file.writelines(new_msg) - except IOError as e: - raise RuntimeError(f"Failed to write commit message to {MERGE_MSG}: {e}") from e +def resolve_ticket_alias(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None: + """Fold the deprecated --ticket flag into --jira. -def cherry_pick(sha, ciq_tags, jira_ticket, ignore_fixes_check): - """ - Cherry picks a commit and it adds the ciq standardized format - In case of error (cherry pick conflict): - - MERGE_MSG.bak contains the original commit message - - MERGE_MSG contains the standardized commit message - - Conflict has to be solved manually - In case of runtime errors that are not cherry pick conflicts, the cherry - pick changes are reverted. (git reset --hard HEAD) - - In case of success: - - the commit is cherry picked - - MERGE_MSG.bak is deleted - - You can still see MERGE_MSG for the original message + Same pattern as resolve_sha_alias: --ticket names the same parameter as + --jira. Passing both with conflicting values is an error. """ + ticket = getattr(args, "ticket", None) + if ticket is None: + return - # Expand the provided SHA1 to the full SHA1 in case it's either abbreviated or an expression - try: - full_sha = CIQ_get_full_hash(repo=os.getcwd(), short_hash=sha) - except RuntimeError as e: - raise RuntimeError(f"Invalid commit SHA {sha}: {e}") from e - - check_fixes(sha=full_sha, ignore_fixes_check=ignore_fixes_check) - - # Commit message is in MERGE_MSG - commit_successful = True - try: - CIQ_run_git(repo_path=os.getcwd(), args=["cherry-pick", "-nsx", full_sha]) - except RuntimeError: - commit_successful = False - - try: - manage_commit_message( - full_sha=full_sha, ciq_tags=ciq_tags, jira_ticket=jira_ticket, commit_successful=commit_successful - ) - except RuntimeError as e: - CIQ_reset_HEAD(repo=os.getcwd()) - raise RuntimeError(f"Could not create proper commit message: {e}") from e - - if not commit_successful: - error_str = ( - f"[FAILED] git cherry-pick -nsx {full_sha}\n" - "Manually resolve conflict and add explanation under `upstream-diff` tag in commit message\n" - ) - raise CherryPickException(error_str) + if args.jira and args.jira != ticket: + parser.error("--jira and --ticket are aliases; specify only one of them") - CIQ_run_git(repo_path=os.getcwd(), args=["commit", "-F", MERGE_MSG]) + args.jira = ticket + args.ticket = None -def cherry_pick_fixes( - sha, ciq_tags, jira_ticket, upstream_ref, ignore_fixes_check, jira_instance, vulns_repo, jira_dry_run -): - """ - Check upstream_ref for commits that have this reference: - Fixes: . If any, these will also be cherry picked with the ciq - tag = cve-bf. If the tag was cve-pre, it stays the same. - """ - fixes_in_mainline = CIQ_find_fixes_in_mainline_current_branch_unapplied( - repo=os.getcwd(), upstream_ref=upstream_ref, hash_=sha - ) +def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: + upstream_remotes = UpstreamRemotes.from_yaml() + if args.commit and args.commit != "-": + args.commit = CIQ_get_full_hash(repo=os.getcwd(), short_hash=args.commit) - # Replace cve with cve-bf - # Leave cve-pre and cve-bf as they are - bf_ciq_tags = [re.sub(r"^cve ", "cve-bf ", tag.strip()) for tag in ciq_tags] - for full_hash, display_str in fixes_in_mainline: - print(f"Extra cherry picking {display_str}") - full_cherry_pick( - sha=full_hash, - ciq_tags=bf_ciq_tags, - jira_ticket=jira_ticket, - upstream_ref=upstream_ref, - ignore_fixes_check=ignore_fixes_check, - jira_instance=jira_instance, - vulns_repo=vulns_repo, - jira_dry_run=jira_dry_run, + if args.commit_source_sha and args.commit_source: + upstream_remotes.fetch_for_commit_source( + commit_source=args.commit_source, sha=args.commit_source_sha, repo_dir=Path(os.getcwd()) ) + args.commit_source_sha = CIQ_get_full_hash(repo=os.getcwd(), short_hash=args.commit_source_sha) + args_dict = vars(args) + tags = [] + if args.ciq_tag is not None: + tags = args.ciq_tag.split(",") # TODO make it smarter maybe idk -def full_cherry_pick( - sha, ciq_tags, jira_ticket, upstream_ref, ignore_fixes_check, jira_instance, vulns_repo, jira_dry_run -): - """ - Cherry picks a commit from upstream-ref along with its Fixes: references. - If cherry-pick or cherry_pick_fixes fail, the exception is propagated - If one of the cherry picks fails, an exception is returned and the previous - successful cherry picks are left as they are. - """ - - # Double check if cve number and jira matches the actual commit - updated_ciq_tags, updated_jira_ticket = check_cve_number( - sha=sha, - ciq_tags=ciq_tags, - jira_ticket=jira_ticket, - jira_instance=jira_instance, - vulns_repo=vulns_repo, - ) - - # Cherry pick the commit - try: - cherry_pick( - sha=sha, - ciq_tags=updated_ciq_tags, - jira_ticket=updated_jira_ticket, - ignore_fixes_check=ignore_fixes_check, - ) - except (CherryPickException, RuntimeError) as e: - update_jira_failure(jira_instance=jira_instance, ticket_key=updated_jira_ticket, jira_dry_run=jira_dry_run) - raise e + for tag in tags: + name, value = tag.strip().split(" ", 1) + # Tag names are hyphenated (cve-pre, cve-bf, ...); CommitHeader + # fields are underscored. + args_dict[name.replace("-", "_")] = value - # Cherry pick the fixed-by dependencies - try: - cherry_pick_fixes( - sha=sha, - ciq_tags=updated_ciq_tags, - jira_ticket=updated_jira_ticket, - upstream_ref=upstream_ref, - ignore_fixes_check=ignore_fixes_check, - jira_instance=jira_instance, - vulns_repo=vulns_repo, - jira_dry_run=jira_dry_run, - ) - except (CherryPickException, RuntimeError) as e: - # TODO would add some extra information to jira if the cve-bf deps are not applied - raise e + print(args) - # Update jira only if its deps are applied as well - update_jira_success(jira_instance=jira_instance, ticket_key=updated_jira_ticket, jira_dry_run=jira_dry_run) + return CommitHeader.from_dict(args_dict) if __name__ == "__main__": @@ -337,8 +371,27 @@ def full_cherry_pick( logging.basicConfig(level=logging.INFO) parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) - parser.add_argument("--sha", help="Target SHA1 to cherry-pick", required=True) - parser.add_argument("--ticket", help="Ticket associated to cherry-pick work, comma separated list is supported.") + parser.add_argument( + "--commit", + dest="commit", + required=False, + help="Target SHA1 to cherry-pick", + ) # TODO check if commit exists or not + parser.add_argument( + "--sha", + dest="sha", + required=False, + help="Deprecated alias for --commit (kept for existing callers, e.g. kernel-tools cve_remediation_automation)", + ) + parser.add_argument( + "--jira", dest="jira", help="Ticket associated to cherry-pick work, comma separated list is supported." + ) + parser.add_argument( + "--ticket", + dest="ticket", + required=False, + help="Deprecated alias for --jira (kept for existing callers, e.g. kernel-tools cve_remediation_automation)", + ) parser.add_argument( "--ciq-tag", help="Tags for commit message <-optional modifier> .\n" @@ -352,11 +405,26 @@ def full_cherry_pick( default="origin/kernel-mainline", help="Reference to upstream mainline branch (default: origin/kernel-mainline)", ) + # TODO upstream ref, what is this?? + parser.add_argument( + "--commit-source", + help="Reference to upstream remote name if mainline is not the source", + ) + parser.add_argument( + "--commit-source-sha", help="Actual commit sha of the commit is fetched from other sources than mainline" + ) + parser.add_argument( "--ignore-fixes-check", action="store_true", help="Continue even if the commit(s) referenced in Fixes: tags are not present in the current branch", ) + parser.add_argument( + "--pick-fixes", + action="store_true", + help="Cherry pick Fixes: commits as well (cve-bf)", + ) + parser.add_argument( "--jira-url", required=False, @@ -383,48 +451,18 @@ def full_cherry_pick( ) args = parser.parse_args() - jira_url = args.jira_url or os.environ.get("JIRA_URL") - jira_user = args.jira_user or os.environ.get("JIRA_API_USER") - jira_key = args.jira_key or os.environ.get("JIRA_API_TOKEN") + resolve_sha_alias(args=args, parser=parser) + resolve_ticket_alias(args=args, parser=parser) - if not all([jira_url, jira_user, jira_key]): - print("[NOTE]: JIRA credentials not provided. Set via --jira-* args or environment variables.") - jira_instance = None - else: - jira_instance = JiraInstance(server_url=jira_url, api_user=jira_user, api_key=jira_key, project_key="VULN") + # Let construction errors propagate: swallowing them here would crash + # later on an uninitialized cherry_pick_command anyway. + cherry_pick_command = CherryPickCommand(args=args) - if args.ciq_tag is not None: - try: - CIQ_setup_vulns_repo(vulns_repo=args.vulns_dir) - except RuntimeError as e: - print(e) - sys.exit(1) - - # Expand the provided SHA1 to the full SHA1 in case it's either abbreviated or an expression - git_sha_res = subprocess.run(["git", "show", "--pretty=%H", "-s", args.sha], stdout=subprocess.PIPE) - if git_sha_res.returncode != 0: - print(f"[FAILED] git show --pretty=%H -s {args.sha}") - print("Subprocess Call:") - print(git_sha_res) - print("") - else: - args.sha = git_sha_res.stdout.decode("utf-8").strip() - - tags = [] - if args.ciq_tag is not None: - tags = args.ciq_tag.split(",") + commit_header = from_argsparse_to_commit_header(args) + print(commit_header) try: - full_cherry_pick( - sha=args.sha, - ciq_tags=tags, - jira_ticket=args.ticket, - upstream_ref=args.upstream_ref, - ignore_fixes_check=args.ignore_fixes_check, - jira_instance=jira_instance, - vulns_repo=args.vulns_dir, - jira_dry_run=args.jira_dry_run, - ) + cherry_pick_command.full_cherry_pick(commit_header=commit_header) except CherryPickException as e: logging.error(e) sys.exit(1) diff --git a/ciq-tag.py b/ciq-tag.py index d6ba5d8..05ff194 100755 --- a/ciq-tag.py +++ b/ciq-tag.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 -import ciq_tag -import click +import logging import os -from enum import Enum import sys -import logging +from enum import Enum + +import click + +import kt.ktlib.ciq_tag as ciq_tag DEFAULT_LOGLEVEL = "INFO" diff --git a/jira_pr_check.py b/jira_pr_check.py index 1798f31..f92b3cd 100755 --- a/jira_pr_check.py +++ b/jira_pr_check.py @@ -8,6 +8,7 @@ from jira import JIRA +from kt.ktlib.commit_header import CommitHeader from release_config import jira_field_map, release_map CVE_PATTERN = r"CVE-\d{4}-\d{4,7}" @@ -154,47 +155,15 @@ def main(): sys.exit(1) commit_msg = result.stdout.strip() + commit_header = CommitHeader.from_commit_body(commit_body=commit_msg) + print(commit_header) lines = commit_msg.split("\n") # Extract summary line (first line) summary = lines[0] if lines else "" - # Extract header (start after first blank line, end at next blank line) - header_lines = [] - in_header = False - vuln_tickets = [] - commit_cves = [] - - for i, line in enumerate(lines): - if i == 0: # Skip summary line - continue - if not in_header and line.strip() == "": # First blank line, start of header - in_header = True - continue - if in_header and line.strip() == "": # Second blank line, end of header - break - if in_header: - header_lines.append(line) - stripped = line.strip() - - # Check for jira line with VULN - if stripped.lower().startswith("jira ") and "vuln-" in stripped.lower(): - parts = stripped.split() - for part in parts[1:]: # Skip 'jira' keyword - if part.upper().startswith("VULN-"): - vuln_tickets.append(part.upper()) - - # Check for CVE line - # Assume format: "cve CVE-YYYY-NNNN", "cve-bf CVE-YYYY-NNNN", or "cve-pre CVE-YYYY-NNNN" - # There will only be one CVE per line, but possibly multiple CVEs listed - if stripped.lower().startswith(("cve ", "cve-bf ", "cve-pre ")): - parts = stripped.split() - for part in parts[1:]: # Skip 'cve'/'cve-bf'/'cve-pre' keyword/tag - # CVES always start with CVE- - if part.upper().startswith("CVE-"): - commit_cves.append(part.upper()) - - header = "\n".join(header_lines) + vuln_tickets = [commit_header.jira] if commit_header.jira else [] + commit_cves = commit_header.all_cves() # Check VULN tickets against merge target lts_match = None @@ -324,7 +293,7 @@ def main(): { "sha": sha, "summary": summary, - "header": header, + "header": commit_header.to_str(), "full_message": commit_msg, "vuln_tickets": vuln_tickets, "lts_match": lts_match, diff --git a/kt/ktlib/ciq_helpers.py b/kt/ktlib/ciq_helpers.py index b695a71..2c9426f 100644 --- a/kt/ktlib/ciq_helpers.py +++ b/kt/ktlib/ciq_helpers.py @@ -12,6 +12,8 @@ import git +from kt.ktlib.commit_header import CommitHeader + def process_full_commit_message(commit): """Process the full git commit message specific to the CIQ Kernel Tools. @@ -106,29 +108,21 @@ def get_backport_commit_data(repo, branch, common_ancestor, allow_duplicates=Fal return upstream_commits, True -def CIQ_cherry_pick_commit_standardization(lines, commit, tags=None, jira="", optional_msg=""): +# This should have CommitHeader, body + + +def CIQ_cherry_pick_commit_standardization(lines, commit_header: CommitHeader): """Standardize CIQ the cherry-pick commit message. Parameters: lines: Original SHAS commit message. - commit: The commit SHA1 that was cherry-picked. - tags: A list of tags to add to the commit message. - jira: The JIRA number to add to the commit message, this can be a comma separated list. - optional_msg: An optional message to add to the commit message. Traditionally used for `upstream-diff`. + commit_header: CommitHeader dataclass that represents ciq custom tags. Return: The modified commit message passed in as lines. """ # assemble in reverse by inserting lines below first blank line (line 2) lines.insert(2, "\n") - if optional_msg != "": - lines.insert(2, f"{optional_msg}\n") - lines.insert(2, f"commit {commit}\n") - if tags: - for tag in tags[::-1]: - lines.insert(2, f"{tag}\n") - if jira: - for i in jira.split(","): - lines.insert(2, f"jira {i.strip()}\n") + lines.insert(2, f"{commit_header.to_str()}\n") # We Need to indent lines that have email addresss as some tooling in the community # will atttempt to read these lines and email everyone on the list. We do not want @@ -151,13 +145,13 @@ def CIQ_cherry_pick_commit_standardization(lines, commit, tags=None, jira="", op return lines -def CIQ_original_commit_author_to_tag_string(repo_path, sha): - """This will grab the original commit author and return the "tag" we use for the CIQ based header +def CIQ_original_commit_author(repo_path, sha): + """This will return the original commit author Parameters: repo_path: pwd to the repository with the kernel mainline remote sha: this is the full commit sha we're going to backport - Return: String for Tag + Return: String for author """ git_auth_res = subprocess.run( ["git", "show", '--pretty="%aN <%aE>"', "--no-patch", sha], @@ -169,7 +163,8 @@ def CIQ_original_commit_author_to_tag_string(repo_path, sha): print(f"[FAILED] git show --pretty='%aN <%aE>' --no-patch {sha}") print(f"[FAILED][STDERR:{git_auth_res.returncode}] {git_auth_res.stderr.decode('utf-8')}") return None - return "commit-author " + git_auth_res.stdout.decode("utf-8").replace('"', "").strip() + + return git_auth_res.stdout.decode("utf-8").replace('"', "").strip() def CIQ_run_git(repo_path, args): diff --git a/ciq_tag.py b/kt/ktlib/ciq_tag.py similarity index 99% rename from ciq_tag.py rename to kt/ktlib/ciq_tag.py index 033b85f..9b3f62a 100644 --- a/ciq_tag.py +++ b/kt/ktlib/ciq_tag.py @@ -76,6 +76,9 @@ class CiqTag(Enum): REBUILD_FUZZ = ["Rebuild_FUZZ"] COMMIT_AUTHOR = ["commit-author"] COMMIT = ["commit"] + COMMIT_SOURCE = ["commit-source"] + COMMIT_SOURCE_SHA = ["commit-source-sha"] + COMMIT_SOURCE_AUTHOR = ["commit-source-author"] EMPTY_COMMIT = (["Empty-Commit"], True) UPSTREAM_DIFF = (["upstream-diff"], True) diff --git a/kt/ktlib/commit_header.py b/kt/ktlib/commit_header.py new file mode 100644 index 0000000..206f1e7 --- /dev/null +++ b/kt/ktlib/commit_header.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import inspect +from dataclasses import asdict, dataclass +from typing import Optional + +from kt.ktlib.ciq_tag import CiqMsg + + +@dataclass +class CommitHeader: + """ + Name-field view of the CIQ tag block in a commit message. + At the moment it represents only commits for cve fixes. + """ + + jira: Optional[str] = None + # TODO maybe have a union of these cve_bf, cve_pre and cve) idk + cve: Optional[str] = None + cve_bf: Optional[str] = None + cve_pre: Optional[str] = None + commit_author: Optional[str] = None + commit: Optional[str] = None + commit_source: Optional[str] = None + commit_source_sha: Optional[str] = None + commit_source_author: Optional[str] = None + upstream_diff: Optional[str] = None + + @classmethod + def from_dict(cls, env): + return cls(**{k: v for k, v in env.items() if k in inspect.signature(cls).parameters}) + + @classmethod + def from_ciq_msg(cls, ciq_msg: CiqMsg) -> CommitHeader: + # CiqMsg has multible values for tags and their values, that's overkill, we will always use one, the first one + msg_dict = {} + for tag, value in ciq_msg._tags_dict.items(): + # TODO a bug here around upstream-diff from CiqMsg, this is an workaround + if type(tag.value) is tuple: + name = tag.value[0][0] + else: + name = tag.value[0] + + tag_name = name.replace("-", "_") + tag_value = value[0][1]._value + msg_dict[tag_name] = tag_value + + return cls(**msg_dict) + + @classmethod + def from_commit_body(cls, commit_body: str) -> CommitHeader: + ciq_msg = CiqMsg(commit_body) + return cls.from_ciq_msg(ciq_msg=ciq_msg) + + def to_str(self) -> str: + commit_header_dict = asdict(self) + return "\n".join(f"{key.replace('_', '-')} {value}" for key, value in commit_header_dict.items() if value) + + def is_cve(self) -> bool: + return self.cve or self.cve_bf or self.cve_pre + + # A commit header may have multiple cve tags. But we are interested if it has the cve tag, since that + # is the commit it represents + # If not cve tag, check cve-bf or cve-pre + # we may have situations where a commit has (cve, cve-pre) or (cve, cve-bf) because a deps may be a cve itself, because we do not enforce this, but our current tooling uses only tag + def cve_number(self) -> str: + if self.cve: + return self.cve + + if self.cve_bf: + return self.cve_bf + + if self.cve_pre: + return self.cve_pre + + def set_cve(self, cve_number): + # Make sure only one cve tag is used + # Our current tooling uses only 1 tag at the moment, but this may change and result in a commit that has 2 cve tags (cve, cve-bf) or (cve, cve-pre) for commits that are deps but cve as well + self.cve_bf = None + self.cve_pre = None + self.cve = cve_number + print(f"CVE {cve_number} for hash {self.commit} and original tags {self.print_cve_tags()}") + + def print_cve_tags(self) -> str: + if not self.is_cve(): + return "" + + # TODO add a priint with the original tag situation + return "" + + def make_it_cve_bf(self): + # Interchange cve with cve-bf + if not self.is_cve(): + return + + # It already has a cve_bf val, it's fine + if self.cve_bf: + return + + self.cve_bf = self.cve + self.cve = None + + def all_cves(self) -> list: + """Return a list of cve numbers""" + + cves = [] + if self.cve: + cves.append(self.cve) + + if self.cve_bf: + cves.append(self.cve_bf) + + if self.cve_pre: + cves.append(self.cve_pre) + + return cves + + def extract_upstream_name_and_sha(self): + """Return a pair of (commit_source, commit_source_sha) if source is not mainline + Otherwise returns (None, commit) if the source is mainline + """ + + if self.commit_source_sha: + return (self.commit_source, self.commit_source_sha) + + if self.commit and self.commit != "-": + return (None, self.commit) + + return (None, None) diff --git a/kt/ktlib/git_remote.py b/kt/ktlib/git_remote.py new file mode 100644 index 0000000..4bb42fb --- /dev/null +++ b/kt/ktlib/git_remote.py @@ -0,0 +1,124 @@ +from dataclasses import dataclass +import logging + +import yaml +from git import Repo +from git.exc import GitCommandError +from pathlib3x import Path + +UPSTREAM_SOURCE_FILEPATH = Path(__file__).parent.parent.parent.joinpath("upstream_sources.yaml") + + +def _sha_in_local_repo(repo_dir: Path, sha: str) -> bool: + """True if sha resolves to a commit object in the local repository.""" + try: + result = Repo(repo_dir).git.rev_parse("--verify", "--quiet", f"{sha}^{{commit}}") + return bool(result) + except GitCommandError: + return False + + +@dataclass +class GitRemoteInfo: + """ + Dataclass that a git remote + name: name of remote + url: url from where we can fetch the remote + """ + + name: str + url: str + + def _add_remote(self, repo_dir: Path = "."): + """Adds remote in repo_dir""" + repo = Repo(repo_dir) + repo.create_remote(name=self.name, url=self.url) + + def fetch(self, repo_dir: Path = "."): + """ + Fetch remote repo_dir. + """ + repo = Repo(repo_dir) + if self.name not in repo.remotes: + self._add_remote(repo_dir=repo_dir) + + remote = repo.remotes[self.name] + if remote.url != self.url: + # Fetch from the existing url; never silently rewrite remotes the + # user already configured. + logging.warning( + f"remote '{self.name}' exists with url {remote.url}, " + f"different from configured {self.url}; fetching from the existing url" + ) + remote.fetch() + + +@dataclass +class UpstreamRemotes: + remotes: dict[str, GitRemoteInfo] + + @classmethod + def from_yaml(cls): + data = None + + with open(UPSTREAM_SOURCE_FILEPATH) as f: + data = yaml.safe_load(f) + + return cls.from_dict(data=data) + + @classmethod + def from_dict(cls, data: dict): + remotes_dict = {} + for name, url in data.items(): + git_remote_info = GitRemoteInfo(name=name, url=url) + remotes_dict[name] = git_remote_info + + return cls(remotes=remotes_dict) + + def fetch_remote(self, remote_name: str, repo_dir: Path): + git_remote_info = self.remotes[remote_name] + git_remote_info.fetch(repo_dir=repo_dir) + + def fetch_for_commit_source(self, commit_source: str, sha: str, repo_dir: Path): + """Ensure the objects for commit_source/commit_source_sha are available locally. + + commit_source is a provenance label from a commit header's `commit-source` + tag and can be either: + - a remote name configured in upstream_sources.yaml (e.g. "centos-stream-9"), + - or a branch name (e.g. "linux-6.12.y") carried by one of the configured + remotes. + + Resolution order: + 1. sha already resolvable locally -> no-op (no network). + 2. commit_source is an upstream_sources.yaml key -> fetch that remote. + 3. commit_source is a branch -> ls-remote the configured remotes (ref + query only, no objects) and fetch the first one carrying + refs/heads/. + 4. Otherwise raise RuntimeError. + + Raises: + RuntimeError: if commit_source cannot be resolved to a remote. + """ + # 1. Fast path: objects are already local. + if _sha_in_local_repo(repo_dir=repo_dir, sha=sha): + return + + # 2. Remote-name provenance (upstream_sources.yaml key). + if commit_source in self.remotes: + self.fetch_remote(remote_name=commit_source, repo_dir=repo_dir) + return + + # 3. Branch-style provenance: find a configured remote carrying the branch. + for git_remote_info in self.remotes.values(): + ls = Repo(repo_dir).git.ls_remote(git_remote_info.url, f"refs/heads/{commit_source}") + if ls.strip(): + logging.info(f"branch {commit_source} found on remote {git_remote_info.name}, fetching") + git_remote_info.fetch(repo_dir=repo_dir) + return + + raise RuntimeError( + f"commit-source '{commit_source}' is neither an upstream source " + f"({list(self.remotes.keys())}) nor a branch on any of them; " + f"and sha {sha} is not available locally. " + f"Fetch the branch manually and retry." + ) diff --git a/run_interdiff.py b/run_interdiff.py index 3bf8ded..a773d74 100755 --- a/run_interdiff.py +++ b/run_interdiff.py @@ -2,11 +2,13 @@ import argparse import os -import re import subprocess import sys import tempfile +from kt.ktlib.commit_header import CommitHeader +from kt.ktlib.git_remote import UpstreamRemotes + def run_git(repo, args): """Run a git command in the given repository and return its output as a string.""" @@ -52,15 +54,6 @@ def get_short_hash_and_subject(repo, sha): raise RuntimeError(f"Failed to get short hash and subject for {sha}: {e}") -def extract_upstream_hash(msg): - """Extract the upstream commit hash from a commit message. - Looks for lines like 'commit ' in the commit message.""" - match = re.search(r"^commit\s+([0-9a-fA-F]{12,40})", msg, re.MULTILINE) - if match: - return match.group(1) - return None - - def run_interdiff(repo, backport_sha, upstream_sha, interdiff_path): """Run interdiff comparing the backport commit with the upstream commit. Returns (success, output) tuple.""" @@ -159,6 +152,8 @@ def main(): print("Please fetch or create the required references before running this script.") sys.exit(1) + upstream_remotes = UpstreamRemotes.from_yaml() + # Get all PR commits pr_commits = get_pr_commits(args.repo, args.pr_branch, args.base_branch) if not pr_commits: @@ -178,7 +173,16 @@ def main(): pr_commit_desc = f"{short_hash} ({subject})" msg = get_commit_message(args.repo, sha) - upstream_hash = extract_upstream_hash(msg) + commit_header = CommitHeader.from_commit_body(commit_body=msg) + + (upstream_remote, upstream_hash) = commit_header.extract_upstream_name_and_sha() + if upstream_remote and upstream_hash: + # Make sure the objects are available locally so interdiff works. + # commit-source can be a remote name (e.g. centos-stream-9) or a + # branch (e.g. linux-6.12.y); fetch_for_commit_source handles both. + upstream_remotes.fetch_for_commit_source( + commit_source=upstream_remote, sha=upstream_hash, repo_dir=args.repo + ) except RuntimeError as e: # Handle errors getting commit information any_differences = True diff --git a/tests/kt/ktlib/test_ciq_helpers.py b/tests/kt/ktlib/test_ciq_helpers.py index 6d7d395..3491f55 100644 --- a/tests/kt/ktlib/test_ciq_helpers.py +++ b/tests/kt/ktlib/test_ciq_helpers.py @@ -1,11 +1,12 @@ from kt.ktlib.ciq_helpers import CIQ_cherry_pick_commit_standardization, read_spec_el_version +from kt.ktlib.commit_header import CommitHeader UPSTREAM_SHA = "1234567890abcdef1234567890abcdef12345678" -AUTHOR_TAG = "commit-author Upstream Author " def standardized_cherry_pick_msg(): """Run standardization on commit message lines mimicking MERGE_MSG after `git cherry-pick -nsx`.""" + commit_header = CommitHeader(jira="VULN-123", cve="CVE-2026-1234", commit=UPSTREAM_SHA) lines = [ "gve: fix a bug in the driver\n", "\n", @@ -16,7 +17,7 @@ def standardized_cherry_pick_msg(): f"(cherry picked from commit {UPSTREAM_SHA})\n", "Signed-off-by: Backporter \n", ] - return CIQ_cherry_pick_commit_standardization(lines, UPSTREAM_SHA, tags=[AUTHOR_TAG], jira="VULN-123") + return CIQ_cherry_pick_commit_standardization(lines, commit_header) def test_cherry_pick_standardization_indents_upstream_trailers(): @@ -31,6 +32,51 @@ def test_cherry_pick_standardization_stops_indenting_at_marker(): assert lines[-1] == "Signed-off-by: Backporter \n" +def test_cherry_pick_standardization_emits_header_tags_as_lines(): + """The commit header must be inserted as real message lines, not a Python list repr.""" + lines = standardized_cherry_pick_msg() + text = "".join(lines) + + assert "jira VULN-123\n" in text + assert "cve CVE-2026-1234\n" in text + assert f"commit {UPSTREAM_SHA}\n" in text + assert "['" not in text # regression: to_str() list repr leaking into the message + + +def test_cherry_pick_standardization_header_order(): + lines = standardized_cherry_pick_msg() + text = "".join(lines) + + jira_pos = text.index("jira VULN-123") + cve_pos = text.index("cve CVE-2026-1234") + commit_pos = text.index(f"commit {UPSTREAM_SHA}") + assert jira_pos < cve_pos < commit_pos + + +def test_cherry_pick_standardization_with_commit_source(): + """Source-only pick (KERNEL-516): commit-source tags are emitted.""" + commit_header = CommitHeader( + jira="VULN-181881", + cve_bf="CVE-2026-31431", + commit="-", + commit_source="centos-stream-9", + commit_source_sha="5db6ef9847717329f12c5ea8aba7e9f588a980c0", + ) + lines = [ + "authencesn: reject invalid authsize\n", + "\n", + "Body text.\n", + "\n", + "\tSigned-off-by: Yucheng Lu \n", + ] + result = CIQ_cherry_pick_commit_standardization(lines, commit_header) + text = "".join(result) + + assert "commit -\n" in text + assert "commit-source centos-stream-9\n" in text + assert "commit-source-sha 5db6ef9847717329f12c5ea8aba7e9f588a980c0\n" in text + + # --- read_spec_el_version tests --- diff --git a/tests/kt/ktlib/test_commit_header.py b/tests/kt/ktlib/test_commit_header.py new file mode 100644 index 0000000..593fd43 --- /dev/null +++ b/tests/kt/ktlib/test_commit_header.py @@ -0,0 +1,235 @@ +"""Tests for kt.ktlib.commit_header.CommitHeader.""" + +import re + +import pytest + +from kt.ktlib.commit_header import CommitHeader + +MAINLINE_SHA = "2abd5287f08319fa35764566b15c6e22cb1068db" +SOURCE_SHA = "5db6ef9847717329f12c5ea8aba7e9f588a980c0" + +# Real-world shaped body (JIRA KERNEL-516 example): source-only commit, +# mainline sha not known yet (commit -). +SOURCE_COMMIT_BODY = """ +jira VULN-181881 +cve-bf CVE-2026-31431 +commit-author Yucheng Lu +commit - +commit-source-sha 5db6ef9847717329f12c5ea8aba7e9f588a980c0 +commit-source centos-stream-9 + +authencesn requires either a zero authsize or an authsize of at least +4 bytes because the ESN encrypt/decrypt paths always move 4 bytes of +high-order sequence number data at the end of the authenticated data. + + Signed-off-by: Yucheng Lu + Signed-off-by: Herbert Xu + +""" + +MAINLINE_COMMIT_BODY = f""" +jira VULN-195551 +cve CVE-2026-64561 +commit-author Sean Christopherson +commit {MAINLINE_SHA} + +KVM: x86: Check for invalid/obsolete root after making MMU pages available + + Signed-off-by: Sean Christopherson + +""" + + +# --- parsing --- + + +def test_from_commit_body_source_only(): + header = CommitHeader.from_commit_body(commit_body=SOURCE_COMMIT_BODY) + + assert header.jira == "VULN-181881" + assert header.cve_bf == "CVE-2026-31431" + assert header.commit_author == "Yucheng Lu " + assert header.commit == "-" + assert header.commit_source == "centos-stream-9" + assert header.commit_source_sha == SOURCE_SHA + + +def test_from_commit_body_mainline(): + header = CommitHeader.from_commit_body(commit_body=MAINLINE_COMMIT_BODY) + + assert header.jira == "VULN-195551" + assert header.cve == "CVE-2026-64561" + assert header.commit == MAINLINE_SHA + assert header.commit_source is None + assert header.commit_source_sha is None + + +def test_from_commit_body_ignores_body_lines(): + """Tag parsing must stop at the tags block; body text is not misread as tags.""" + header = CommitHeader.from_commit_body(commit_body=MAINLINE_COMMIT_BODY) + + # 'commit ' appearing in prose must not overwrite the parsed sha + assert header.commit == MAINLINE_SHA + + +# --- to_str round trip --- + + +def test_to_str_returns_tag_lines(): + header = CommitHeader( + jira="VULN-181881", + cve_bf="CVE-2026-31431", + commit="-", + commit_source="centos-stream-9", + commit_source_sha=SOURCE_SHA, + ) + + result = header.to_str() + + assert result == ( + "jira VULN-181881\n" + "cve-bf CVE-2026-31431\n" + "commit -\n" + "commit-source centos-stream-9\n" + "commit-source-sha 5db6ef9847717329f12c5ea8aba7e9f588a980c0" + ) + + +def test_to_str_round_trip_source_only(): + """from_commit_body -> to_str -> from_commit_body must be stable.""" + header = CommitHeader.from_commit_body(commit_body=SOURCE_COMMIT_BODY) + + rebuilt = CommitHeader.from_commit_body(commit_body="\n" + header.to_str() + "\n\n" + "body text\n") + + assert rebuilt == header + + +def test_to_str_omits_empty_fields(): + header = CommitHeader(commit=MAINLINE_SHA) + + assert header.to_str() == f"commit {MAINLINE_SHA}" + + +def test_to_str_no_fields_set(): + assert CommitHeader().to_str() == "" + + +# --- from_dict filtering --- + + +def test_from_dict_ignores_unknown_keys(): + header = CommitHeader.from_dict({"jira": "VULN-1", "commit": MAINLINE_SHA, "not_a_field": "x"}) + + assert header.jira == "VULN-1" + assert header.commit == MAINLINE_SHA + assert not hasattr(header, "not_a_field") + + +# --- cve helpers --- + + +def test_is_cve_true_for_each_variant(): + assert CommitHeader(cve="CVE-1").is_cve() + assert CommitHeader(cve_bf="CVE-1").is_cve() + assert CommitHeader(cve_pre="CVE-1").is_cve() + + +def test_is_cve_false(): + assert not CommitHeader(commit=MAINLINE_SHA).is_cve() + + +def test_cve_number_prefers_cve(): + assert CommitHeader(cve="CVE-1", cve_bf="CVE-2").cve_number() == "CVE-1" + + +def test_cve_number_falls_back_to_bf_then_pre(): + assert CommitHeader(cve_bf="CVE-2").cve_number() == "CVE-2" + assert CommitHeader(cve_pre="CVE-3").cve_number() == "CVE-3" + assert CommitHeader().cve_number() is None + + +def test_set_cve_clears_other_cve_tags(): + header = CommitHeader(cve_bf="CVE-2", cve_pre="CVE-3") + + header.set_cve("CVE-9") + + assert header.cve == "CVE-9" + assert header.cve_bf is None + assert header.cve_pre is None + + +def test_make_it_cve_bf_swaps_cve(): + header = CommitHeader(cve="CVE-1") + + header.make_it_cve_bf() + + assert header.cve is None + assert header.cve_bf == "CVE-1" + + +def test_make_it_cve_bf_keeps_existing_bf(): + header = CommitHeader(cve="CVE-1", cve_bf="CVE-2") + + header.make_it_cve_bf() + + # Already has a cve_bf value - leave it alone + assert header.cve == "CVE-1" + assert header.cve_bf == "CVE-2" + + +def test_make_it_cve_bf_noop_without_cve(): + header = CommitHeader(commit=MAINLINE_SHA) + + header.make_it_cve_bf() + + assert not header.is_cve() + + +def test_all_cves(): + assert CommitHeader(cve="CVE-1", cve_bf="CVE-2", cve_pre="CVE-3").all_cves() == ["CVE-1", "CVE-2", "CVE-3"] + assert CommitHeader(commit=MAINLINE_SHA).all_cves() == [] + + +# --- extract_upstream_name_and_sha --- + + +def test_extract_upstream_mainline(): + header = CommitHeader(commit=MAINLINE_SHA) + + assert header.extract_upstream_name_and_sha() == (None, MAINLINE_SHA) + + +def test_extract_upstream_source(): + header = CommitHeader(commit="-", commit_source="centos-stream-9", commit_source_sha=SOURCE_SHA) + + assert header.extract_upstream_name_and_sha() == ("centos-stream-9", SOURCE_SHA) + + +def test_extract_upstream_source_with_mainline_known(): + """Both shas present: the source sha is what gets cherry-picked.""" + header = CommitHeader(commit=MAINLINE_SHA, commit_source="linux-stable", commit_source_sha=SOURCE_SHA) + + assert header.extract_upstream_name_and_sha() == ("linux-stable", SOURCE_SHA) + + +def test_extract_upstream_nothing_known(): + assert CommitHeader().extract_upstream_name_and_sha() == (None, None) + assert CommitHeader(commit="-").extract_upstream_name_and_sha() == (None, None) + + +# --- regression: provenance tags must never look like a mainline anchor line --- + + +@pytest.mark.parametrize( + "field,tag_name", + [("commit_source", "commit-source"), ("commit_source_sha", "commit-source-sha")], +) +def test_source_tags_do_not_match_commit_anchor(field, tag_name): + """check_kernel_commits.py anchors on ^commit <40hex>; the provenance tags + commit-source / commit-source-sha must stay visually and semantically distinct.""" + header = CommitHeader(**{field: SOURCE_SHA}) + line = header.to_str() + + assert not re.match(r"^commit\s+[0-9a-fA-F]{40}$", line) + assert line.startswith(tag_name) diff --git a/tests/kt/ktlib/test_git_remote.py b/tests/kt/ktlib/test_git_remote.py new file mode 100644 index 0000000..3f9c0d4 --- /dev/null +++ b/tests/kt/ktlib/test_git_remote.py @@ -0,0 +1,228 @@ +"""Tests for kt.ktlib.git_remote (UpstreamRemotes / GitRemoteInfo).""" + +import pytest +from git import Repo +from pathlib3x import Path + +from kt.ktlib.git_remote import UpstreamRemotes + +UPSTREAM_SOURCES = { + "kernel-mainline": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", + "linux-stable": "https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git", +} + + +@pytest.fixture +def bare_remote(tmp_path): + """A bare remote with one branch, as a fetch source.""" + bare = tmp_path / "remote.git" + Repo.init(str(bare), bare=True) + + seed = tmp_path / "seed" + seed_repo = Repo.init(str(seed)) + with seed_repo.config_writer() as cw: + cw.set_value("user", "name", "Tester") + cw.set_value("user", "email", "t@t.com") + (seed / "file.txt").write_text("content") + seed_repo.index.add(["file.txt"]) + seed_repo.index.commit("initial commit") + seed_repo.create_remote("origin", str(bare)) + seed_repo.remotes.origin.push("HEAD:refs/heads/main") + + return bare + + +@pytest.fixture +def local_repo(tmp_path): + """A local repo the remote will be added to / fetched into.""" + repo = Repo.init(tmp_path / "local") + with repo.config_writer() as cw: + cw.set_value("user", "name", "Tester") + cw.set_value("user", "email", "t@t.com") + (tmp_path / "local" / "readme").write_text("x") + repo.index.add(["readme"]) + repo.index.commit("init") + return repo + + +# --- UpstreamRemotes --- + + +def test_from_dict_builds_all_remotes(): + remotes = UpstreamRemotes.from_dict(data=UPSTREAM_SOURCES) + + assert set(remotes.remotes.keys()) == set(UPSTREAM_SOURCES.keys()) + for name, url in UPSTREAM_SOURCES.items(): + assert remotes.remotes[name].name == name + assert remotes.remotes[name].url == url + + +def test_from_dict_empty(): + remotes = UpstreamRemotes.from_dict(data={}) + + assert remotes.remotes == {} + + +def test_fetch_remote_unknown_name_raises(): + remotes = UpstreamRemotes.from_dict(data=UPSTREAM_SOURCES) + + with pytest.raises(KeyError): + remotes.fetch_remote(remote_name="does-not-exist", repo_dir=Path(".")) + + +# --- GitRemoteInfo.fetch --- + + +def test_fetch_creates_remote_and_fetches(local_repo, bare_remote): + remotes = UpstreamRemotes.from_dict(data={"mainline": str(bare_remote)}) + + remotes.fetch_remote(remote_name="mainline", repo_dir=Path(str(local_repo.working_dir))) + + assert "mainline" in [r.name for r in local_repo.remotes] + assert local_repo.remotes["mainline"].url == str(bare_remote) + # The fetched branch is visible as a remote ref + refs = [str(r) for r in local_repo.remotes["mainline"].refs] + assert any("main" in ref for ref in refs) + + +def test_fetch_is_idempotent_existing_remote(local_repo, bare_remote): + """Fetching with the remote already present must not duplicate it.""" + local_repo.create_remote("mainline", str(bare_remote)) + remotes = UpstreamRemotes.from_dict(data={"mainline": str(bare_remote)}) + + remotes.fetch_remote(remote_name="mainline", repo_dir=Path(str(local_repo.working_dir))) + + names = [r.name for r in local_repo.remotes] + assert names.count("mainline") == 1 + + +def test_fetch_warns_on_url_mismatch_existing_remote(local_repo, bare_remote, caplog): + """If the remote exists pointing elsewhere, fetch proceeds from the + existing url but logs a warning (never silently rewritten).""" + import logging + + other = Path(str(bare_remote)).parent / "other.git" + other_repo = Repo.init(str(other), bare=True) + # give the other remote an object so fetch has something to do + seed = Path(str(bare_remote)).parent / "seed_other" + seed_repo = Repo.init(str(seed)) + (seed / "f.txt").write_text("x") + seed_repo.index.add(["f.txt"]) + seed_repo.index.commit("c") + seed_repo.create_remote("origin", str(other)) + seed_repo.remotes.origin.push("HEAD:refs/heads/main") + del other_repo + + local_repo.create_remote("mainline", str(other)) + remotes = UpstreamRemotes.from_dict(data={"mainline": str(bare_remote)}) + + with caplog.at_level(logging.WARNING): + remotes.fetch_remote(remote_name="mainline", repo_dir=Path(str(local_repo.working_dir))) + + # Remote was NOT rewritten + assert local_repo.remotes["mainline"].url == str(other) + assert "different from configured" in caplog.text + + +# --- UpstreamRemotes.fetch_for_commit_source --- + + +def _make_source_commit(target, msg="source commit"): + """Create a commit with a distinct file and return its full sha. + + target may be a work repo (seeded directly) or a bare remote (seeded via + a temp work repo and pushed), mirroring the bare_remote fixture pattern. + """ + repo = Repo(str(target)) + if repo.bare: + seed = Path(str(target)).parent / f"seed_{msg.replace(' ', '_')}" + seed_repo = Repo.init(str(seed)) + with seed_repo.config_writer() as cw: + cw.set_value("user", "name", "Tester") + cw.set_value("user", "email", "t@t.com") + (seed / "src.txt").write_text(msg) + seed_repo.index.add(["src.txt"]) + commit = seed_repo.index.commit(msg) + seed_repo.create_remote("origin", str(target)) + seed_repo.remotes.origin.push(f"HEAD:refs/heads/{msg.split()[0]}") + return commit.hexsha + + (Path(str(target)) / "src.txt").write_text(msg) + repo.index.add(["src.txt"]) + return repo.index.commit(msg).hexsha + + +class TestFetchForCommitSource: + def test_sha_local_is_noop(self, local_repo): + """Objects already local -> no fetch, no network.""" + remotes = UpstreamRemotes.from_dict(data={}) + sha = _make_source_commit(local_repo.working_dir) + + # No remotes configured: must still succeed because the sha is local + remotes.fetch_for_commit_source( + commit_source="linux-6.12.y", sha=sha, repo_dir=Path(str(local_repo.working_dir)) + ) + + def test_remote_name_source_fetches_that_remote(self, local_repo, bare_remote): + """commit_source matching an upstream_sources.yaml key -> that remote fetched.""" + sha = _make_source_commit(bare_remote, "seedcommit main") + # sha not local yet: give local_repo a different history + (Path(str(local_repo.working_dir)) / "other.txt").write_text("x") + local_repo.index.add(["other.txt"]) + local_repo.index.commit("other") + + remotes = UpstreamRemotes.from_dict(data={"centos-stream-9": str(bare_remote)}) + remotes.fetch_for_commit_source( + commit_source="centos-stream-9", sha=sha, repo_dir=Path(str(local_repo.working_dir)) + ) + + refs = [str(r) for r in local_repo.remotes["centos-stream-9"].refs] + assert any("main" in ref for ref in refs) + # sha now resolvable + local_repo.git.rev_parse("--verify", f"{sha}^{{commit}}") + + def test_branch_source_resolves_and_fetches(self, local_repo, bare_remote): + """commit_source as a branch name -> ls-remote finds it, remote fetched.""" + sha = _make_source_commit(bare_remote, "linux-6.12.y main") + remotes = UpstreamRemotes.from_dict(data={"linux-stable": str(bare_remote)}) + + remotes.fetch_for_commit_source( + commit_source="linux-6.12.y", sha=sha, repo_dir=Path(str(local_repo.working_dir)) + ) + + assert "linux-stable" in [r.name for r in local_repo.remotes] + # the branch was fetched and the sha is now resolvable + refs = [str(r) for r in local_repo.remotes["linux-stable"].refs] + assert any("linux-6.12.y" in ref for ref in refs) + local_repo.git.rev_parse("--verify", f"{sha}^{{commit}}") + + def test_branch_source_unknown_branch_raises(self, local_repo, bare_remote): + remotes = UpstreamRemotes.from_dict(data={"linux-stable": str(bare_remote)}) + + with pytest.raises(RuntimeError, match="neither an upstream source"): + remotes.fetch_for_commit_source( + commit_source="linux-9.99.y", sha="1" * 40, repo_dir=Path(str(local_repo.working_dir)) + ) + + def test_branch_source_no_remotes_configured_raises(self, local_repo): + remotes = UpstreamRemotes.from_dict(data={}) + + with pytest.raises(RuntimeError, match="neither an upstream source"): + remotes.fetch_for_commit_source( + commit_source="linux-6.12.y", sha="2" * 40, repo_dir=Path(str(local_repo.working_dir)) + ) + + def test_remote_name_preferred_over_branch_resolution(self, local_repo, tmp_path): + """If commit_source is a yaml key, fetch that remote even if a sha is unknown.""" + url = str(tmp_path / "c9.git") + Repo.init(url, bare=True) + remotes = UpstreamRemotes.from_dict(data={"centos-stream-9": url}) + + # Unknown sha: the fetch happens (fetch of an empty remote succeeds and + # the subsequent rev-parse would fail - but fetch_for_commit_source's + # contract is "fetch the remote", not "guarantee the sha"). + remotes.fetch_for_commit_source( + commit_source="centos-stream-9", sha="3" * 40, repo_dir=Path(str(local_repo.working_dir)) + ) + + assert "centos-stream-9" in [r.name for r in local_repo.remotes] diff --git a/tests/test_ciq_cherry_pick.py b/tests/test_ciq_cherry_pick.py index 58db7da..9602975 100644 --- a/tests/test_ciq_cherry_pick.py +++ b/tests/test_ciq_cherry_pick.py @@ -1,10 +1,11 @@ -"""Tests for ciq-cherry-pick.py +"""Tests for ciq-cherry-pick.py ({rnicolescu}_commit_source CherryPickCommand API). Imports the module via importlib (hyphenated filename, module-level -git.Repo(os.getcwd()) call) against temporary git repos. Test fixtures -use real commit data from ciqlts9_6 and rocky9_6 in ctrliq/kernel-src-tree. +git.Repo(os.getcwd()) call) against temporary git repos. Commit data in the +fixtures mirrors real backports (drm/xe, KVM, net/sched) from ctrliq/kernel-src-tree. """ +import argparse import importlib.util import logging import os @@ -15,8 +16,34 @@ import git import pytest +from kt.ktlib.commit_header import CommitHeader + SCRIPT_PATH = str(Path(__file__).resolve().parent.parent / "ciq-cherry-pick.py") +JIRA_ENV_VARS = ["JIRA_URL", "JIRA_API_USER", "JIRA_API_TOKEN", "KERNEL_JIRA_API_USER", "KERNEL_JIRA_API_KEY"] + + +def make_args(**overrides): + """argparse.Namespace matching ciq-cherry-pick.py's parser.""" + defaults = dict( + commit=None, + sha=None, + jira=None, + ciq_tag=None, + upstream_ref="origin/kernel-mainline", + commit_source=None, + commit_source_sha=None, + ignore_fixes_check=False, + pick_fixes=False, + jira_url=None, + jira_user=None, + jira_key=None, + jira_dry_run=False, + vulns_dir="../vulns", + ) + defaults.update(overrides) + return argparse.Namespace(**defaults) + @pytest.fixture def cherry_pick_env(tmp_path, monkeypatch): @@ -24,6 +51,11 @@ def cherry_pick_env(tmp_path, monkeypatch): Returns (module, repo, tmp_path). """ + # The ambient environment may carry real JIRA credentials; the command + # would construct a live JiraInstance from them. + for var in JIRA_ENV_VARS: + monkeypatch.delenv(var, raising=False) + repo = git.Repo.init(tmp_path) with repo.config_writer() as cw: cw.set_value("user", "name", "Backporter") @@ -49,6 +81,13 @@ def cherry_pick_env(tmp_path, monkeypatch): del sys.modules["ciq_cherry_pick"] +@pytest.fixture +def command(cherry_pick_env): + """A CherryPickCommand built from default args (no JIRA instance).""" + mod, _, _ = cherry_pick_env + return mod.CherryPickCommand(args=make_args()) + + def _create_upstream_commit(repo, tmp_path, msg, file_content, author_name, author_email): """Create a commit on a fresh branch from the current HEAD and return to the original branch.""" original = repo.active_branch.name @@ -63,51 +102,39 @@ def _create_upstream_commit(repo, tmp_path, msg, file_content, author_name, auth return commit -# --------------------------------------------------------------------------- -# extract_cve_from_tag -# --------------------------------------------------------------------------- - - -class TestExtractCveFromTag: - def test_cve_tag(self, cherry_pick_env): - mod, _, _ = cherry_pick_env - assert mod.extract_cve_from_tag("cve CVE-2026-52976") == "CVE-2026-52976" +def _write_merge_msg(mod, lines): + with open(mod.MERGE_MSG, "w") as f: + f.writelines(lines) - def test_cve_bf_tag(self, cherry_pick_env): - mod, _, _ = cherry_pick_env - assert mod.extract_cve_from_tag("cve-bf CVE-2025-38653") == "CVE-2025-38653" - def test_cve_pre_not_matched(self, cherry_pick_env): - mod, _, _ = cherry_pick_env - assert mod.extract_cve_from_tag("cve-pre CVE-2024-53216") is None +def _read_merge_msg(mod): + with open(mod.MERGE_MSG) as f: + return f.read() - def test_no_match(self, cherry_pick_env): - mod, _, _ = cherry_pick_env - assert mod.extract_cve_from_tag("random text") is None - def test_embedded_after_jira(self, cherry_pick_env): - mod, _, _ = cherry_pick_env - assert mod.extract_cve_from_tag("jira VULN-123, cve CVE-2025-5678") == "CVE-2025-5678" +def _merge_msg_lines(sha): + """MERGE_MSG content mimicking `git cherry-pick -nsx `.""" + return [ + "KVM: x86: Check for invalid root\n", + "\n", + "Check for a stale page fault.\n", + "\n", + "Signed-off-by: Sean Christopherson \n", + f"(cherry picked from commit {sha})\n", + "Signed-off-by: Backporter \n", + ] # --------------------------------------------------------------------------- -# manage_commit_message +# CherryPickCommand._manage_commit_message # --------------------------------------------------------------------------- class TestManageCommitMessage: - """Modeled after ciqlts9_6 10d418025314 (drm/xe) and 0df72fa942dc (KVM).""" - - def _write_merge_msg(self, mod, lines): - with open(mod.MERGE_MSG, "w") as f: - f.writelines(lines) - - def _read_merge_msg(self, mod): - with open(mod.MERGE_MSG) as f: - return f.read() + def _make_cmd(self, mod): + return mod.CherryPickCommand(args=make_args()) def test_standardizes_cve_commit(self, cherry_pick_env): - """Verify full CIQ header: jira, cve, commit-author, commit.""" mod, repo, tmp_path = cherry_pick_env commit = _create_upstream_commit( @@ -116,7 +143,6 @@ def test_standardizes_cve_commit(self, cherry_pick_env): msg=( "drm/xe: Fix error cleanup in xe_exec_queue_create_ioctl()\n\n" "Two error handling issues exist.\n\n" - 'Fixes: 7970cb36966c ("drm/xe/hw_engine_group")\n' "Signed-off-by: Shuicheng Lin \n" ), file_content="// fixed\nint qfq_init(void) { return 1; }\n", @@ -124,184 +150,160 @@ def test_standardizes_cve_commit(self, cherry_pick_env): author_email="shuicheng.lin@intel.com", ) sha = commit.hexsha + _write_merge_msg(mod, _merge_msg_lines(sha)) - self._write_merge_msg( - mod, - [ - "drm/xe: Fix error cleanup in xe_exec_queue_create_ioctl()\n", - "\n", - "Two error handling issues exist.\n", - "\n", - 'Fixes: 7970cb36966c ("drm/xe/hw_engine_group")\n', - "Signed-off-by: Shuicheng Lin \n", - f"(cherry picked from commit {sha})\n", - "Signed-off-by: Backporter \n", - ], - ) - - mod.manage_commit_message( - full_sha=sha, - ciq_tags=["cve CVE-2026-52976"], - jira_ticket="VULN-189406", - commit_successful=True, - ) + cmd = self._make_cmd(mod) + header = CommitHeader(jira="VULN-189406", cve="CVE-2026-52976", commit=sha) + cmd._manage_commit_message(commit_header=header, commit_successful=True) - result = self._read_merge_msg(mod) + result = _read_merge_msg(mod) assert "jira VULN-189406\n" in result assert "cve CVE-2026-52976\n" in result - assert "commit-author Shuicheng Lin \n" in result assert f"commit {sha}\n" in result assert "upstream-diff" not in result - def test_conflict_adds_upstream_diff(self, cherry_pick_env): - """commit_successful=False should insert upstream-diff marker.""" + def test_conflict_sets_upstream_diff_marker(self, cherry_pick_env): mod, repo, tmp_path = cherry_pick_env commit = _create_upstream_commit( repo, tmp_path, - msg="KVM: x86: Check for invalid root\n\nCheck for stale fault.\n\n" - "Signed-off-by: Sean Christopherson \n", + msg="KVM: x86: Check for invalid root\n\nCheck for stale fault.\n\n", file_content="// kvm fix\n", author_name="Sean Christopherson", author_email="seanjc@google.com", ) sha = commit.hexsha + _write_merge_msg(mod, _merge_msg_lines(sha)) - self._write_merge_msg( - mod, - [ - "KVM: x86: Check for invalid root\n", - "\n", - "Check for stale fault.\n", - "\n", - "Signed-off-by: Sean Christopherson \n", - f"(cherry picked from commit {sha})\n", - "Signed-off-by: Backporter \n", - ], - ) - - mod.manage_commit_message( - full_sha=sha, - ciq_tags=["cve CVE-2026-64561"], - jira_ticket="VULN-195551", - commit_successful=False, - ) + cmd = self._make_cmd(mod) + header = CommitHeader(jira="VULN-195551", cve="CVE-2026-64561", commit=sha) + cmd._manage_commit_message(commit_header=header, commit_successful=False) - result = self._read_merge_msg(mod) + result = _read_merge_msg(mod) assert "upstream-diff |\n" in result assert "jira VULN-195551\n" in result def test_header_ordering(self, cherry_pick_env): - """jira → cve → commit-author → commit, matching ciqlts9_6 format.""" + """jira -> cve -> commit-author -> commit, matching the CIQ format.""" mod, repo, tmp_path = cherry_pick_env commit = _create_upstream_commit( repo, tmp_path, - msg="nfsd: fix UAF\n\nFix UAF.\n\nSigned-off-by: Yang Erkun \n", + msg="nfsd: fix UAF\n\nFix UAF.\n\n", file_content="// nfsd fix\n", author_name="Yang Erkun", author_email="yangerkun@huawei.com", ) sha = commit.hexsha - - self._write_merge_msg( - mod, - [ - "nfsd: fix UAF\n", - "\n", - "Fix UAF.\n", - "\n", - "Signed-off-by: Yang Erkun \n", - f"(cherry picked from commit {sha})\n", - "Signed-off-by: Backporter \n", - ], - ) - - mod.manage_commit_message( - full_sha=sha, - ciq_tags=["cve CVE-2024-53216"], - jira_ticket="VULN-167075", - commit_successful=True, + _write_merge_msg(mod, _merge_msg_lines(sha)) + + cmd = self._make_cmd(mod) + header = CommitHeader( + jira="VULN-167075", + cve="CVE-2024-53216", + commit_author="Yang Erkun ", + commit=sha, ) + cmd._manage_commit_message(commit_header=header, commit_successful=True) - result = self._read_merge_msg(mod) + result = _read_merge_msg(mod) jira_pos = result.index("jira VULN-167075") cve_pos = result.index("cve CVE-2024-53216") author_pos = result.index("commit-author Yang Erkun") commit_pos = result.index(f"commit {sha}") assert jira_pos < cve_pos < author_pos < commit_pos - def test_backup_created(self, cherry_pick_env): + def test_commit_source_tags_emitted(self, cherry_pick_env): + """KERNEL-516: source-only pick writes commit-source / commit-source-sha tags.""" mod, repo, tmp_path = cherry_pick_env commit = _create_upstream_commit( repo, tmp_path, - msg="fix something\n\nSigned-off-by: A \n", - file_content="// v2\n", - author_name="A", - author_email="a@b.com", + msg="authencesn: reject invalid authsize\n\nBody.\n\n", + file_content="// fix\n", + author_name="Yucheng Lu", + author_email="kanolyc@gmail.com", ) - - self._write_merge_msg( - mod, - [ - "fix something\n", - "\n", - "Signed-off-by: A \n", - f"(cherry picked from commit {commit.hexsha})\n", - "Signed-off-by: Backporter \n", - ], + source_sha = commit.hexsha + _write_merge_msg(mod, _merge_msg_lines(source_sha)) + + cmd = self._make_cmd(mod) + header = CommitHeader( + jira="VULN-181881", + cve_bf="CVE-2026-31431", + commit="-", + commit_source="centos-stream-9", + commit_source_sha=source_sha, ) + cmd._manage_commit_message(commit_header=header, commit_successful=True) - mod.manage_commit_message(commit.hexsha, [], None, True) - assert os.path.exists(mod.MERGE_MSG_BAK) + result = _read_merge_msg(mod) + assert "commit -\n" in result + assert "commit-source centos-stream-9\n" in result + assert f"commit-source-sha {source_sha}\n" in result - def test_multiple_jira_tickets(self, cherry_pick_env): + def test_conflicts_block_stripped(self, cherry_pick_env): + """git appends a '# Conflicts:' comment block on conflicted picks; it must not + survive into the final commit message.""" mod, repo, tmp_path = cherry_pick_env commit = _create_upstream_commit( repo, tmp_path, - msg="fix two things\n\nSigned-off-by: A \n", - file_content="// multi\n", + msg="fix\n\nBody.\n\n", + file_content="// fix\n", author_name="A", author_email="a@b.com", ) + sha = commit.hexsha + lines = _merge_msg_lines(sha) + [ + "# Conflicts:\n", + "#\tnet/sched/sch_qfq.c\n", + ] + _write_merge_msg(mod, lines) - self._write_merge_msg( - mod, - [ - "fix two things\n", - "\n", - "Signed-off-by: A \n", - f"(cherry picked from commit {commit.hexsha})\n", - "Signed-off-by: Backporter \n", - ], + cmd = self._make_cmd(mod) + header = CommitHeader(jira="VULN-1", commit=sha) + cmd._manage_commit_message(commit_header=header, commit_successful=False) + + result = _read_merge_msg(mod) + assert "# Conflicts:" not in result + assert "sch_qfq.c\n" not in result + + def test_backup_created(self, cherry_pick_env): + mod, repo, tmp_path = cherry_pick_env + + commit = _create_upstream_commit( + repo, + tmp_path, + msg="fix something\n\n", + file_content="// v2\n", + author_name="A", + author_email="a@b.com", ) + sha = commit.hexsha + _write_merge_msg(mod, _merge_msg_lines(sha)) - mod.manage_commit_message(commit.hexsha, [], "VULN-100,VULN-200", True) + cmd = self._make_cmd(mod) + cmd._manage_commit_message(commit_header=CommitHeader(commit=sha), commit_successful=True) - result = self._read_merge_msg(mod) - assert "jira VULN-100\n" in result - assert "jira VULN-200\n" in result + assert os.path.exists(mod.MERGE_MSG_BAK) # --------------------------------------------------------------------------- -# cherry_pick — full integration +# CherryPickCommand._cherry_pick — full integration # --------------------------------------------------------------------------- class TestCherryPick: - """Integration tests using real git cherry-pick operations. + def _make_cmd(self, mod, **overrides): + return mod.CherryPickCommand(args=make_args(**overrides)) - Modeled after ciqlts9_6 0df72fa942dc (KVM) and 42796dd9cd76 (net/sched). - """ - - def test_successful_cherry_pick(self, cherry_pick_env): - """Clean cherry-pick produces CIQ-standardized commit on current branch.""" + def test_successful_cherry_pick_mainline(self, cherry_pick_env): + """Clean mainline cherry-pick produces the CIQ-standardized commit.""" mod, repo, tmp_path = cherry_pick_env commit = _create_upstream_commit( @@ -311,19 +313,15 @@ def test_successful_cherry_pick(self, cherry_pick_env): "KVM: x86: Check for invalid root after making MMU pages available\n\n" "Check for a stale page fault.\n\n" "Signed-off-by: Sean Christopherson \n" - "Signed-off-by: Paolo Bonzini \n" ), - file_content=("// QFQ scheduler\nint qfq_init(void) { return 0; }\n// KVM fix applied\n"), + file_content="// QFQ scheduler\nint qfq_init(void) { return 0; }\n// KVM fix applied\n", author_name="Sean Christopherson", author_email="seanjc@google.com", ) - mod.cherry_pick( - sha=commit.hexsha, - ciq_tags=["cve CVE-2026-64561"], - jira_ticket="VULN-195551", - ignore_fixes_check=True, - ) + cmd = self._make_cmd(mod) + header = CommitHeader(jira="VULN-195551", cve="CVE-2026-64561", commit=commit.hexsha) + cmd._cherry_pick(commit_header=header) msg = repo.head.commit.message assert "jira VULN-195551" in msg @@ -331,38 +329,62 @@ def test_successful_cherry_pick(self, cherry_pick_env): assert "commit-author Sean Christopherson " in msg assert f"commit {commit.hexsha}" in msg assert f"(cherry picked from commit {commit.hexsha})" in msg + # author is resolved from the picked commit into the header + assert header.commit_author == "Sean Christopherson " def test_cherry_pick_with_cve_bf(self, cherry_pick_env): - """Cherry-pick with cve-bf tag, modeled after ciqlts9_6 64a032d7cc69.""" mod, repo, tmp_path = cherry_pick_env commit = _create_upstream_commit( repo, tmp_path, - msg=( - "proc: fix type confusion in pde_set_flags()\n\n" - "Add !S_ISDIR test before calling pde_set_flags().\n\n" - "Signed-off-by: wangzijie \n" - ), + msg="proc: fix type confusion in pde_set_flags()\n\nAdd !S_ISDIR test.\n\n", file_content="// proc fix\nint qfq_init(void) { return 0; }\n", author_name="wangzijie", author_email="wangzijie1@honor.com", ) - mod.cherry_pick( - sha=commit.hexsha, - ciq_tags=["cve-bf CVE-2025-38653"], - jira_ticket="VULN-163195", - ignore_fixes_check=True, - ) + cmd = self._make_cmd(mod) + header = CommitHeader(jira="VULN-163195", cve_bf="CVE-2025-38653", commit=commit.hexsha) + cmd._cherry_pick(commit_header=header) msg = repo.head.commit.message assert "cve-bf CVE-2025-38653" in msg assert "jira VULN-163195" in msg assert "commit-author wangzijie " in msg + def test_cherry_pick_from_commit_source(self, cherry_pick_env): + """KERNEL-516: with commit_source_sha set, the source sha is what gets + cherry-picked (extract_upstream_name_and_sha routing).""" + mod, repo, tmp_path = cherry_pick_env + + source_commit = _create_upstream_commit( + repo, + tmp_path, + msg="authencesn: reject invalid authsize\n\nBody.\n\n", + file_content="// centos variant\nint qfq_init(void) { return 0; }\n// fix applied\n", + author_name="Yucheng Lu", + author_email="kanolyc@gmail.com", + ) + + cmd = self._make_cmd(mod) + header = CommitHeader( + jira="VULN-181881", + cve_bf="CVE-2026-31431", + commit="-", + commit_source="centos-stream-9", + commit_source_sha=source_commit.hexsha, + ) + cmd._cherry_pick(commit_header=header) + + msg = repo.head.commit.message + assert f"(cherry picked from commit {source_commit.hexsha})" in msg + assert "commit-source centos-stream-9" in msg + assert "commit-author Yucheng Lu " in msg + def test_cherry_pick_conflict_raises(self, cherry_pick_env): - """Conflicting cherry-pick raises CherryPickException, MERGE_MSG has CIQ header.""" + """Conflicting cherry-pick raises CherryPickException; MERGE_MSG has the + standardized header with upstream-diff marker.""" mod, repo, tmp_path = cherry_pick_env commit = _create_upstream_commit( @@ -385,17 +407,13 @@ def test_cherry_pick_conflict_raises(self, cherry_pick_env): repo.index.add(["net/sched/sch_qfq.c"]) repo.index.commit("Conflicting change on main") - with pytest.raises(mod.CherryPickException): - mod.cherry_pick( - sha=commit.hexsha, - ciq_tags=["cve CVE-2026-53264"], - jira_ticket="VULN-189813", - ignore_fixes_check=True, - ) + cmd = self._make_cmd(mod) + header = CommitHeader(jira="VULN-189813", cve="CVE-2026-53264", commit=commit.hexsha) - with open(mod.MERGE_MSG) as f: - merge_msg = f.read() + with pytest.raises(mod.CherryPickException): + cmd._cherry_pick(commit_header=header) + merge_msg = _read_merge_msg(mod) assert "jira VULN-189813" in merge_msg assert "cve CVE-2026-53264" in merge_msg assert "upstream-diff |" in merge_msg @@ -407,22 +425,14 @@ def test_cherry_pick_no_tags(self, cherry_pick_env): commit = _create_upstream_commit( repo, tmp_path, - msg=( - "SUNRPC: introduce cache_check_rcu\n\n" - "Prepare patch for rcu context check.\n\n" - "Signed-off-by: Yang Erkun \n" - ), + msg="SUNRPC: introduce cache_check_rcu\n\nPrepare patch for rcu context check.\n\n", file_content="// sunrpc\nint qfq_init(void) { return 0; }\n", author_name="Yang Erkun", author_email="yangerkun@huawei.com", ) - mod.cherry_pick( - sha=commit.hexsha, - ciq_tags=[], - jira_ticket=None, - ignore_fixes_check=True, - ) + cmd = self._make_cmd(mod) + cmd._cherry_pick(commit_header=CommitHeader(commit=commit.hexsha)) msg = repo.head.commit.message assert f"commit {commit.hexsha}" in msg @@ -431,30 +441,30 @@ def test_cherry_pick_no_tags(self, cherry_pick_env): # --------------------------------------------------------------------------- -# check_fixes +# CherryPickCommand._check_fixes # --------------------------------------------------------------------------- class TestCheckFixes: - def test_no_fixes_reference_warns(self, cherry_pick_env, caplog): + def test_no_fixes_reference_warns(self, cherry_pick_env, command, caplog): """Commit without Fixes: reference logs a warning.""" mod, repo, tmp_path = cherry_pick_env commit = _create_upstream_commit( repo, tmp_path, - msg="Simple feature\n\nAdd feature.\n\nSigned-off-by: Dev \n", + msg="Simple feature\n\nAdd feature.\n\n", file_content="// feature\n", author_name="Dev", author_email="d@e.com", ) with caplog.at_level(logging.WARNING): - mod.check_fixes(sha=commit.hexsha, ignore_fixes_check=False) + command._check_fixes(sha=commit.hexsha) assert "no fixes" in caplog.text.lower() - def test_fixes_present_in_branch_passes(self, cherry_pick_env): + def test_fixes_present_in_branch_passes(self, cherry_pick_env, command): """Fixes: pointing to initial commit (on current branch) should not raise.""" mod, repo, tmp_path = cherry_pick_env @@ -464,44 +474,39 @@ def test_fixes_present_in_branch_passes(self, cherry_pick_env): commit = _create_upstream_commit( repo, tmp_path, - msg=(f'Fix a regression\n\nFixes: {short_sha} ("Initial QFQ scheduler")\nSigned-off-by: Dev \n'), + msg=(f'Fix a regression\n\nFixes: {short_sha} ("Initial QFQ scheduler")\n'), file_content="// regression fix\n", author_name="Dev", author_email="d@e.com", ) - # Should not raise — the referenced commit is on the current branch - mod.check_fixes(sha=commit.hexsha, ignore_fixes_check=False) + command._check_fixes(sha=commit.hexsha) - def test_fixes_missing_from_branch_raises(self, cherry_pick_env): - """Fixes: pointing to upstream-only commit should raise RuntimeError.""" + def test_fixes_missing_from_branch_raises(self, cherry_pick_env, command): + """Fixes: pointing to an upstream-only commit should raise RuntimeError.""" mod, repo, tmp_path = cherry_pick_env original = repo.active_branch.name - # Create first upstream commit (the one that will be referenced by Fixes:) repo.create_head("upstream-base").checkout() (tmp_path / "net" / "sched" / "sch_qfq.c").write_text("// upstream base change\n") repo.index.add(["net/sched/sch_qfq.c"]) base_commit = repo.index.commit( - "upstream: introduce feature\n\nNew feature.\n\nSigned-off-by: A \n", + "upstream: introduce feature\n\nNew feature.\n\n", author=git.Actor("A", "a@b.com"), ) base_short = base_commit.hexsha[:12] - # Create second upstream commit that fixes the first (tmp_path / "net" / "sched" / "sch_qfq.c").write_text("// upstream fix for base\n") repo.index.add(["net/sched/sch_qfq.c"]) fix_commit = repo.index.commit( - f"upstream: fix regression from feature\n\n" - f'Fixes: {base_short} ("upstream: introduce feature")\n' - f"Signed-off-by: B \n", + f'upstream: fix regression from feature\n\nFixes: {base_short} ("upstream: introduce feature")\n', author=git.Actor("B", "b@c.com"), ) repo.heads[original].checkout() with pytest.raises(RuntimeError, match="not part of the tree"): - mod.check_fixes(sha=fix_commit.hexsha, ignore_fixes_check=False) + command._check_fixes(sha=fix_commit.hexsha) def test_fixes_missing_ignored_when_flag_set(self, cherry_pick_env, caplog): """With ignore_fixes_check=True, missing Fixes: ref warns instead of raising.""" @@ -511,88 +516,465 @@ def test_fixes_missing_ignored_when_flag_set(self, cherry_pick_env, caplog): repo.create_head("upstream-ign").checkout() (tmp_path / "net" / "sched" / "sch_qfq.c").write_text("// base\n") repo.index.add(["net/sched/sch_qfq.c"]) - base = repo.index.commit("base\n\nSigned-off-by: A \n", author=git.Actor("A", "a@b.com")) + base = repo.index.commit("base\n\n", author=git.Actor("A", "a@b.com")) (tmp_path / "net" / "sched" / "sch_qfq.c").write_text("// fix\n") repo.index.add(["net/sched/sch_qfq.c"]) - fix = repo.index.commit( - f'fix\n\nFixes: {base.hexsha[:12]} ("base")\nSigned-off-by: B \n', - author=git.Actor("B", "b@c.com"), - ) + fix = repo.index.commit(f'fix\n\nFixes: {base.hexsha[:12]} ("base")\n', author=git.Actor("B", "b@c.com")) repo.heads[original].checkout() + cmd = mod.CherryPickCommand(args=make_args(ignore_fixes_check=True)) with caplog.at_level(logging.WARNING): - mod.check_fixes(sha=fix.hexsha, ignore_fixes_check=True) + cmd._check_fixes(sha=fix.hexsha) # Should not raise # --------------------------------------------------------------------------- -# update_jira_success / update_jira_failure +# CherryPickCommand._cherry_pick_fixes +# --------------------------------------------------------------------------- + + +class TestCherryPickFixes: + def test_skipped_without_mainline_sha(self, cherry_pick_env, command): + """KERNEL-516: a source-only pick (commit='-') has no mainline sha to + search Fixes: for — must skip cleanly, not crash.""" + mod, _, _ = cherry_pick_env + + header = CommitHeader(jira="VULN-181881", commit="-", commit_source="centos-stream-9") + command.full_cherry_pick = MagicMock() + + command._cherry_pick_fixes(original_commit_header=header) + + command.full_cherry_pick.assert_not_called() + + def test_fixes_found_are_picked_as_cve_bf(self, cherry_pick_env, command): + """A commit in upstream_ref with Fixes: is re-picked with + the cve tag swapped to cve-bf. + + Note: the branch name must not collide with the repo's default branch + (init.defaultBranch may be 'mainline' on some systems). + """ + mod, repo, tmp_path = cherry_pick_env + original = repo.active_branch.name + + # The mainline CVE fix, on a dedicated branch used as upstream_ref + repo.create_head("upstream-mainline").checkout() + (tmp_path / "net" / "sched" / "sch_qfq.c").write_text("// mainline fix\n") + repo.index.add(["net/sched/sch_qfq.c"]) + mainline_fix = repo.index.commit( + "net/sched: fix race\n\nFix the race.\n\n", + author=git.Actor("Upstream Dev", "up@stream.com"), + ) + + # A follow-up commit on mainline referencing it via Fixes: + (tmp_path / "net" / "sched" / "sch_qfq.c").write_text("// mainline fix + followup\n") + repo.index.add(["net/sched/sch_qfq.c"]) + followup = repo.index.commit( + f'net/sched: fix regression from race fix\n\nFixes: {mainline_fix.hexsha[:12]} ("net/sched: fix race")\n', + author=git.Actor("Upstream Dev", "up@stream.com"), + ) + + repo.heads[original].checkout() + + cmd = mod.CherryPickCommand(args=make_args(upstream_ref="upstream-mainline")) + cmd.full_cherry_pick = MagicMock() + + header = CommitHeader(jira="VULN-1", cve="CVE-2026-1234", commit=mainline_fix.hexsha) + cmd._cherry_pick_fixes(original_commit_header=header) + + assert cmd.full_cherry_pick.call_count == 1 + bf_header = cmd.full_cherry_pick.call_args.kwargs["commit_header"] + assert bf_header.commit == followup.hexsha + assert bf_header.cve is None + assert bf_header.cve_bf == "CVE-2026-1234" + + def test_no_fixes_no_pick(self, cherry_pick_env, command): + """No Fixes: commits in upstream_ref -> nothing re-picked.""" + mod, repo, tmp_path = cherry_pick_env + + repo.create_head("upstream-empty").checkout() + (tmp_path / "net" / "sched" / "sch_qfq.c").write_text("// unrelated\n") + repo.index.add(["net/sched/sch_qfq.c"]) + unrelated = repo.index.commit("unrelated change\n\nNo fixes here.\n\n", author=git.Actor("A", "a@b.com")) + + # back to the original (non-upstream) branch + original = [h for h in repo.heads if h.name != "upstream-empty"][0] + original.checkout() + + cmd = mod.CherryPickCommand(args=make_args(upstream_ref="upstream-empty")) + cmd.full_cherry_pick = MagicMock() + + header = CommitHeader(jira="VULN-1", cve="CVE-2026-1234", commit=unrelated.hexsha) + cmd._cherry_pick_fixes(original_commit_header=header) + + cmd.full_cherry_pick.assert_not_called() + + +# --------------------------------------------------------------------------- +# CherryPickCommand JIRA updates # --------------------------------------------------------------------------- class TestUpdateJira: def test_success_dry_run_prints(self, cherry_pick_env, capsys): mod, _, _ = cherry_pick_env - mod.update_jira_success( - jira_instance=MagicMock(), - ticket_key="VULN-189406", - jira_dry_run=True, - ) + cmd = mod.CherryPickCommand(args=make_args(jira_dry_run=True)) + + cmd._update_jira_success(ticket_key="VULN-189406") + out = capsys.readouterr().out assert "DRY-RUN" in out assert "VULN-189406" in out def test_success_calls_jira_methods(self, cherry_pick_env): mod, _, _ = cherry_pick_env + cmd = mod.CherryPickCommand(args=make_args()) jira = MagicMock() - mod.update_jira_success(jira_instance=jira, ticket_key="VULN-189406", jira_dry_run=False) + cmd._jira_instance = jira + + cmd._update_jira_success(ticket_key="VULN-189406") jira.assign_ticket.assert_called_once_with(issue_key="VULN-189406") - jira.transition_issue.assert_called_once_with( - issue_key="VULN-189406", - transition_name="In Progress", - ) - jira.update_labels.assert_called_once_with( - issue_key="VULN-189406", - labels=["automated-patch-applied"], - ) + jira.transition_issue.assert_called_once_with(issue_key="VULN-189406", transition_name="In Progress") + jira.update_labels.assert_called_once_with(issue_key="VULN-189406", labels=["automated-patch-applied"]) jira.add_worklog.assert_called_once() def test_success_none_ticket_is_noop(self, cherry_pick_env): mod, _, _ = cherry_pick_env + cmd = mod.CherryPickCommand(args=make_args()) jira = MagicMock() - mod.update_jira_success(jira_instance=jira, ticket_key=None, jira_dry_run=False) + cmd._jira_instance = jira + + cmd._update_jira_success(ticket_key=None) + jira.assign_ticket.assert_not_called() def test_success_none_jira_is_noop(self, cherry_pick_env): mod, _, _ = cherry_pick_env - mod.update_jira_success(jira_instance=None, ticket_key="VULN-189406", jira_dry_run=False) - # Should not raise + cmd = mod.CherryPickCommand(args=make_args()) + assert cmd._jira_instance is None + + cmd._update_jira_success(ticket_key="VULN-189406") # should not raise def test_failure_dry_run_prints(self, cherry_pick_env, capsys): mod, _, _ = cherry_pick_env - mod.update_jira_failure( - jira_instance=MagicMock(), - ticket_key="VULN-189406", - jira_dry_run=True, - ) + cmd = mod.CherryPickCommand(args=make_args(jira_dry_run=True)) + + cmd._update_jira_failure(ticket_key="VULN-189406") + out = capsys.readouterr().out assert "DRY-RUN" in out assert "automated-patch-failed" in out def test_failure_calls_update_labels(self, cherry_pick_env): mod, _, _ = cherry_pick_env + cmd = mod.CherryPickCommand(args=make_args()) jira = MagicMock() - mod.update_jira_failure(jira_instance=jira, ticket_key="VULN-189406", jira_dry_run=False) - jira.update_labels.assert_called_once_with( - issue_key="VULN-189406", - labels=["automated-patch-failed"], - ) + cmd._jira_instance = jira + + cmd._update_jira_failure(ticket_key="VULN-189406") + + jira.update_labels.assert_called_once_with(issue_key="VULN-189406", labels=["automated-patch-failed"]) def test_failure_none_ticket_is_noop(self, cherry_pick_env): mod, _, _ = cherry_pick_env + cmd = mod.CherryPickCommand(args=make_args()) jira = MagicMock() - mod.update_jira_failure(jira_instance=jira, ticket_key=None, jira_dry_run=False) + cmd._jira_instance = jira + + cmd._update_jira_failure(ticket_key=None) + jira.update_labels.assert_not_called() + + +# --------------------------------------------------------------------------- +# from_argsparse_to_commit_header +# --------------------------------------------------------------------------- + + +class TestFromArgsparseToCommitHeader: + def test_ciq_tags_parsed_into_fields(self, cherry_pick_env): + mod, _, _ = cherry_pick_env + + args = make_args(commit=None, jira="VULN-1", ciq_tag="cve CVE-2026-52976") + header = mod.from_argsparse_to_commit_header(args) + + assert header.cve == "CVE-2026-52976" + assert header.jira == "VULN-1" + + def test_ciq_tags_with_comma_and_space(self, cherry_pick_env): + """'cve CVE-1, cve-pre CVE-2' — the comma form must not crash on the + leading space of the second tag.""" + mod, _, _ = cherry_pick_env + + args = make_args(ciq_tag="cve CVE-2026-52976, cve-pre CVE-2024-53216") + header = mod.from_argsparse_to_commit_header(args) + + assert header.cve == "CVE-2026-52976" + assert header.cve_pre == "CVE-2024-53216" + + def test_multi_word_tag_value(self, cherry_pick_env): + """Tag values containing spaces must be kept whole, not crash the split.""" + mod, _, _ = cherry_pick_env + + args = make_args(ciq_tag="cve CVE-2026-52976 with suffix") + header = mod.from_argsparse_to_commit_header(args) + + assert header.cve == "CVE-2026-52976 with suffix" + + def test_commit_short_sha_expanded(self, cherry_pick_env): + mod, repo, _ = cherry_pick_env + + args = make_args(commit=repo.head.commit.hexsha[:12]) + header = mod.from_argsparse_to_commit_header(args) + + assert header.commit == repo.head.commit.hexsha + + def test_commit_source_sha_expanded_and_remote_fetched(self, cherry_pick_env, monkeypatch): + mod, repo, _ = cherry_pick_env + + source_sha = repo.head.commit.hexsha + + class StubUpstreamRemotes: + last_instance = None + + def __init__(self): + self.fetch_mock = MagicMock() + StubUpstreamRemotes.last_instance = self + + @classmethod + def from_yaml(cls): + return cls() + + def fetch_for_commit_source(self, commit_source, sha, repo_dir): + self.fetch_mock(commit_source=commit_source, sha=sha, repo_dir=repo_dir) + + monkeypatch.setattr(mod, "UpstreamRemotes", StubUpstreamRemotes) + + args = make_args(commit="-", commit_source="centos-stream-9", commit_source_sha=source_sha[:12]) + header = mod.from_argsparse_to_commit_header(args) + + assert StubUpstreamRemotes.last_instance.fetch_mock.call_count == 1 + fetch_kwargs = StubUpstreamRemotes.last_instance.fetch_mock.call_args.kwargs + assert fetch_kwargs["commit_source"] == "centos-stream-9" + assert fetch_kwargs["sha"] == source_sha[:12] + assert header.commit_source == "centos-stream-9" + assert header.commit_source_sha == source_sha + + def test_no_fetch_without_both_source_args(self, cherry_pick_env, monkeypatch): + """Only --commit-source (no sha) must not trigger a remote fetch.""" + mod, _, _ = cherry_pick_env + + class StubUpstreamRemotes: + instances = [] + + def __init__(self): + self.fetch_mock = MagicMock() + StubUpstreamRemotes.instances.append(self) + + @classmethod + def from_yaml(cls): + return cls() + + def fetch_for_commit_source(self, commit_source, sha, repo_dir): + self.fetch_mock(commit_source=commit_source, sha=sha, repo_dir=repo_dir) + + monkeypatch.setattr(mod, "UpstreamRemotes", StubUpstreamRemotes) + + args = make_args(commit_source="centos-stream-9") + header = mod.from_argsparse_to_commit_header(args) + + assert StubUpstreamRemotes.instances[-1].fetch_mock.call_count == 0 + assert header.commit_source == "centos-stream-9" + assert header.commit_source_sha is None + + +# --------------------------------------------------------------------------- +# --sha alias for --commit +# --------------------------------------------------------------------------- + + +class TestShaAlias: + """--sha is accepted as a deprecated alias for --commit. + + Keeps existing callers working, notably kernel-tools' + cve_remediation_automation.py which still invokes + `ciq-cherry-pick.py --sha ...`. + """ + + def _build_parser(self, mod): + """Rebuild the script's argument parser (kept close to __main__).""" + import argparse + + parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument("--commit", dest="commit", required=False, help="Target SHA1 to cherry-pick") + parser.add_argument("--sha", dest="sha", required=False, help="Deprecated alias for --commit") + parser.add_argument("--jira") + parser.add_argument("--ciq-tag") + parser.add_argument("--upstream-ref", default="origin/kernel-mainline") + parser.add_argument("--commit-source") + parser.add_argument("--commit-source-sha") + parser.add_argument("--ignore-fixes-check", action="store_true") + parser.add_argument("--pick-fixes", action="store_true") + parser.add_argument("--jira-url") + parser.add_argument("--jira-user") + parser.add_argument("--jira-key") + parser.add_argument("--jira-dry-run", action="store_true", default=False) + parser.add_argument("--vulns-dir", default="../vulns") + return parser + + def test_sha_flag_sets_commit(self, cherry_pick_env): + mod, repo, _ = cherry_pick_env + + sha = repo.head.commit.hexsha + parser = self._build_parser(mod) + args = parser.parse_args(["--sha", sha]) + mod.resolve_sha_alias(args=args, parser=parser) + + assert args.commit == sha + assert args.sha is None + + def test_commit_flag_sets_commit(self, cherry_pick_env): + mod, repo, _ = cherry_pick_env + + sha = repo.head.commit.hexsha + parser = self._build_parser(mod) + args = parser.parse_args(["--commit", sha]) + mod.resolve_sha_alias(args=args, parser=parser) + + assert args.commit == sha + + def test_sha_and_commit_same_value_ok(self, cherry_pick_env): + mod, repo, _ = cherry_pick_env + + sha = repo.head.commit.hexsha + parser = self._build_parser(mod) + args = parser.parse_args(["--commit", sha, "--sha", sha]) + mod.resolve_sha_alias(args=args, parser=parser) + + assert args.commit == sha + + def test_sha_and_commit_conflicting_values_rejected(self, cherry_pick_env, capsys): + mod, _, _ = cherry_pick_env + + parser = self._build_parser(mod) + args = parser.parse_args(["--commit", "aaa", "--sha", "bbb"]) + + with pytest.raises(SystemExit): + mod.resolve_sha_alias(args=args, parser=parser) + + assert "--commit and --sha are aliases" in capsys.readouterr().err + + def test_neither_flag_set(self, cherry_pick_env): + """Neither --commit nor --sha: header commit stays unset (current behavior).""" + mod, _, _ = cherry_pick_env + + parser = self._build_parser(mod) + args = parser.parse_args([]) + mod.resolve_sha_alias(args=args, parser=parser) + + assert args.commit is None + + def test_sha_alias_end_to_end_header(self, cherry_pick_env): + """--sha through resolve_sha_alias + from_argsparse_to_commit_header lands in the header.""" + mod, repo, _ = cherry_pick_env + + sha = repo.head.commit.hexsha + parser = self._build_parser(mod) + args = parser.parse_args(["--sha", sha]) + mod.resolve_sha_alias(args=args, parser=parser) + args.jira = "VULN-1" + + header = mod.from_argsparse_to_commit_header(args) + + assert header.commit == sha + assert header.jira == "VULN-1" + + +# --------------------------------------------------------------------------- +# --ticket alias for --jira +# --------------------------------------------------------------------------- + + +class TestTicketAlias: + """--ticket is accepted as a deprecated alias for --jira. + + kernel-tools' cve_remediation_automation.py still invokes + `ciq-cherry-pick.py --ticket ...`. + """ + + def _build_parser(self, mod): + import argparse + + parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument("--commit", dest="commit", required=False) + parser.add_argument("--sha", dest="sha", required=False) + parser.add_argument("--jira", dest="jira", required=False) + parser.add_argument("--ticket", dest="ticket", required=False) + parser.add_argument("--ciq-tag") + parser.add_argument("--upstream-ref", default="origin/kernel-mainline") + parser.add_argument("--commit-source") + parser.add_argument("--commit-source-sha") + parser.add_argument("--ignore-fixes-check", action="store_true") + parser.add_argument("--pick-fixes", action="store_true") + parser.add_argument("--jira-url") + parser.add_argument("--jira-user") + parser.add_argument("--jira-key") + parser.add_argument("--jira-dry-run", action="store_true", default=False) + parser.add_argument("--vulns-dir", default="../vulns") + return parser + + def test_ticket_flag_sets_jira(self, cherry_pick_env): + mod, _, _ = cherry_pick_env + + parser = self._build_parser(mod) + args = parser.parse_args(["--ticket", "VULN-185809"]) + mod.resolve_ticket_alias(args=args, parser=parser) + + assert args.jira == "VULN-185809" + assert args.ticket is None + + def test_jira_flag_sets_jira(self, cherry_pick_env): + mod, _, _ = cherry_pick_env + + parser = self._build_parser(mod) + args = parser.parse_args(["--jira", "VULN-185809"]) + mod.resolve_ticket_alias(args=args, parser=parser) + + assert args.jira == "VULN-185809" + + def test_ticket_and_jira_same_value_ok(self, cherry_pick_env): + mod, _, _ = cherry_pick_env + + parser = self._build_parser(mod) + args = parser.parse_args(["--jira", "VULN-1", "--ticket", "VULN-1"]) + mod.resolve_ticket_alias(args=args, parser=parser) + + assert args.jira == "VULN-1" + + def test_ticket_and_jira_conflicting_values_rejected(self, cherry_pick_env, capsys): + mod, _, _ = cherry_pick_env + + parser = self._build_parser(mod) + args = parser.parse_args(["--jira", "VULN-1", "--ticket", "VULN-2"]) + + with pytest.raises(SystemExit): + mod.resolve_ticket_alias(args=args, parser=parser) + + assert "--jira and --ticket are aliases" in capsys.readouterr().err + + def test_ticket_alias_end_to_end_header(self, cherry_pick_env): + """--ticket through the aliases lands as the jira tag in the header.""" + mod, repo, _ = cherry_pick_env + + sha = repo.head.commit.hexsha + parser = self._build_parser(mod) + args = parser.parse_args(["--commit", sha, "--ticket", "VULN-185809"]) + mod.resolve_sha_alias(args=args, parser=parser) + mod.resolve_ticket_alias(args=args, parser=parser) + + header = mod.from_argsparse_to_commit_header(args) + + assert header.commit == sha + assert header.jira == "VULN-185809" diff --git a/upstream_sources.yaml b/upstream_sources.yaml new file mode 100644 index 0000000..cf8aa46 --- /dev/null +++ b/upstream_sources.yaml @@ -0,0 +1,4 @@ +arm64: https://git.kernel.org/pub/scm/linux/kernel/git/arm64/linux.git +centos-stream-9: https://gitlab.com/redhat/centos-stream/src/kernel/centos-stream-9.git +centos-stream-10: https://gitlab.com/redhat/centos-stream/src/kernel/centos-stream-10.git +linux-stable: https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git