From 1eaa02ef192b4c863b91764d9fee5cdda85d6260 Mon Sep 17 00:00:00 2001 From: Roxana Nicolescu Date: Tue, 12 May 2026 13:11:35 +0200 Subject: [PATCH 01/13] ciq_tag.py: Move it to kt/ktlib Signed-off-by: Roxana Nicolescu --- ciq-tag.py | 10 ++++++---- ciq_tag.py => kt/ktlib/ciq_tag.py | 0 2 files changed, 6 insertions(+), 4 deletions(-) rename ciq_tag.py => kt/ktlib/ciq_tag.py (100%) 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/ciq_tag.py b/kt/ktlib/ciq_tag.py similarity index 100% rename from ciq_tag.py rename to kt/ktlib/ciq_tag.py From 1365634ec7b906421f1bdc589e887e629b1a26f4 Mon Sep 17 00:00:00 2001 From: Roxana Nicolescu Date: Thu, 23 Jul 2026 15:03:04 +0200 Subject: [PATCH 02/13] kt/ktlib: Add new commit header class Just an interface that provides more clarity around ciq headers, but needs refining. Especially around getters and setters. Signed-off-by: Roxana Nicolescu --- kt/ktlib/commit_header.py | 55 ++++++++++++++++++++++++++++ tests/kt/ktlib/test_commit_header.py | 47 ++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 kt/ktlib/commit_header.py create mode 100644 tests/kt/ktlib/test_commit_header.py diff --git a/kt/ktlib/commit_header.py b/kt/ktlib/commit_header.py new file mode 100644 index 0000000..52c5534 --- /dev/null +++ b/kt/ktlib/commit_header.py @@ -0,0 +1,55 @@ +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 + cve: Optional[str] = None + cve_bf: Optional[str] = None + cve_pre: Optional[str] = None + commit: Optional[str] = None + commit_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) + result = [f"{key.replace('_', '-')} {value}" for key, value in commit_header_dict.items() if value] + + return result diff --git a/tests/kt/ktlib/test_commit_header.py b/tests/kt/ktlib/test_commit_header.py new file mode 100644 index 0000000..f3936b1 --- /dev/null +++ b/tests/kt/ktlib/test_commit_header.py @@ -0,0 +1,47 @@ +from kt.ktlib.commit_header import CommitHeader + + +def test_from_commit_body(): + commit_body = """ +jira VULN-181881 +cve-bf CVE-2026-31431 +commit-author Yucheng Lu +commit 5db6ef9847717329f12c5ea8aba7e9f588a980c0 + +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. + +While crypto_authenc_esn_setauthsize() already rejects explicit +non-zero authsizes in the range 1..3, crypto_authenc_esn_create() +still copied auth->digestsize into inst->alg.maxauthsize without +validating it. The AEAD core then initialized the tfm's default +authsize from that value. + +As a result, selecting an ahash with digest size 1..3, such as +cbcmac(cipher_null), exposed authencesn instances whose default +authsize was invalid even though setauthsize() would have rejected the +same value. AF_ALG could then trigger the ESN tail handling with a +too-short tag and hit an out-of-bounds access. + +Reject authencesn instances whose ahash digest size is in the invalid +non-zero range 1..3 so that no tfm can inherit an unsupported default +authsize. + +Fixes: f15f05b0a5de ("crypto: ccm - switch to separate cbcmac driver") + Cc: stable@kernel.org + Reported-by: Yifan Wu + Reported-by: Juefei Pu + Co-developed-by: Yuan Tan + Signed-off-by: Yuan Tan + Suggested-by: Xin Liu + Tested-by: Yuhang Zheng + Reviewed-by: Eric Biggers + Signed-off-by: Yucheng Lu + Signed-off-by: Ren Wei + Signed-off-by: Herbert Xu + +""" + commit_header = CommitHeader.from_commit_body(commit_body=commit_body) + result = commit_header.to_str() + assert result == commit_body From 2e0cb758e73e7a6b50f8aadb5dc26433c19e20a2 Mon Sep 17 00:00:00 2001 From: Roxana Nicolescu Date: Fri, 5 Jun 2026 16:38:35 +0200 Subject: [PATCH 03/13] ciq-cherry-pick.py use commitHeader instead Had to change --sha and --ticket params to --commit and --jira to match the CommitHeader dataclass. Also created CherryPickCommand class that keeps track of the arguments given to this script, in order to avoid long function with too many parameters. The logic did not change at all, except for the fact that cherry_pick functions and their helpers receive a CommitHeader object instead of the multiple params. Signed-off-by: Roxana Nicolescu --- ciq-cherry-pick.py | 552 ++++++++++++++++++-------------------- kt/ktlib/ciq_helpers.py | 31 +-- kt/ktlib/commit_header.py | 47 +++- 3 files changed, 313 insertions(+), 317 deletions(-) diff --git a/ciq-cherry-pick.py b/ciq-cherry-pick.py index fa92364..616e8e5 100644 --- a/ciq-cherry-pick.py +++ b/ciq-cherry-pick.py @@ -1,7 +1,7 @@ import argparse +import copy import logging import os -import re import subprocess import sys import traceback @@ -16,12 +16,13 @@ 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.jira import JiraInstance MERGE_MSG = git.Repo(os.getcwd()).git_dir + "/MERGE_MSG" @@ -32,303 +33,279 @@ 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 - if jira_instance is None: - return + origin_cve_number = commit_header.cve_number() - jira_instance.update_labels(issue_key=ticket_key, labels=["automated-patch-failed"]) + 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(), + ) + def _update_jira_failure(self, ticket_key: str): + if ticket_key is None: + return -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 - """ + if self._jira_dry_run: + print(f"[DRY-RUN] Would add a label: automated-patch-failed to {ticket_key}") + return - 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 + if self._jira_instance is None: + return - not_present_fixes = [] - for fix in fixes: - if not CIQ_commit_exists_in_current_branch(os.getcwd(), fix): - not_present_fixes.append(fix) + self._jira_instance.update_labels(issue_key=ticket_key, labels=["automated-patch-failed"]) - 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) + 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 + """ + 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 -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. + not_present_fixes = [] + for fix in fixes: + if not CIQ_commit_exists_in_current_branch(os.getcwd(), fix): + not_present_fixes.append(fix) - Original message location: MERGE_MSG - Makes a copy of the original message in MERGE_MSG_BAK + 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) - The new standardized commit message is written to MERGE_MSG - """ + 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. - subprocess.run(["cp", MERGE_MSG, MERGE_MSG_BAK], check=True) + Original message location: MERGE_MSG + Makes a copy of the original message in MERGE_MSG_BAK - # 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] + The new standardized commit message is written to MERGE_MSG + """ - 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}") + subprocess.run(["cp", MERGE_MSG, MERGE_MSG_BAK], check=True) - 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 - ) + # TODO maybe do it earlier, idk + author = CIQ_original_commit_author(repo_path=os.getcwd(), sha=commit_header.commit) + if author is None: + raise RuntimeError(f"Could not find author of commit {commit_header.commit}") + commit_header.commit_author = author - print(f"Cherry Pick New Message for {full_sha}") - print(f"\n Original Message located here: {MERGE_MSG_BAK}") + 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 - 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 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 - """ + # 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 - # 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 + if not commit_successful: + commit_header.upstream_diff = "|" - check_fixes(sha=full_sha, ignore_fixes_check=ignore_fixes_check) + new_msg = CIQ_cherry_pick_commit_standardization(lines=original_msg, commit_header=commit_header) - # 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 + print(f"Cherry Pick New Message for {commit_header.commit}") + print(f"\n Original Message located here: {MERGE_MSG_BAK}") - 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) + 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 - CIQ_run_git(repo_path=os.getcwd(), args=["commit", "-F", MERGE_MSG]) + # 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 + """ + + # 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=commit_header.commit) + except RuntimeError as e: + raise RuntimeError(f"Invalid commit SHA {commit_header.commit}: {e}") from e + commit_header.commit = full_sha + self._check_fixes(sha=full_sha) -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 - ) + # 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 - # 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, + 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 {full_sha}\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_in_mainline = CIQ_find_fixes_in_mainline_current_branch_unapplied( + repo=os.getcwd(), upstream_ref=self._upstream_ref, hash_=original_commit_header.commit ) + 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 + self.full_cherry_pick(commit_header=bf_commit_header) -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 +def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: + args_dict = vars(args) + tags = [] + if args.ciq_tag is not None: + tags = args.ciq_tag.split(",") # TODO make it smarter maybe idk - # 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 + for tag in tags: + name, value = tag.split(" ") + args_dict[name] = value + 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 +314,8 @@ 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", help="Target SHA1 to cherry-pick", required=True) + parser.add_argument("--jira", help="Ticket associated to cherry-pick work, comma separated list is supported.") parser.add_argument( "--ciq-tag", help="Tags for commit message <-optional modifier> .\n" @@ -383,48 +360,27 @@ 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") - - 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") - - 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) - + # TODO move this to CommitHEADEr maybe # 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) + git_sha_res = subprocess.run(["git", "show", "--pretty=%H", "-s", args.commit], 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() + args.commit = git_sha_res.stdout.decode("utf-8").strip() - tags = [] - if args.ciq_tag is not None: - tags = args.ciq_tag.split(",") + try: + cherry_pick_command = CherryPickCommand(args=args) + except Exception as e: + print(f"Setting chery pick command went wrong {e}") + + 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/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/kt/ktlib/commit_header.py b/kt/ktlib/commit_header.py index 52c5534..98419e7 100644 --- a/kt/ktlib/commit_header.py +++ b/kt/ktlib/commit_header.py @@ -15,11 +15,12 @@ class CommitHeader: """ 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: Optional[str] = None commit_author: Optional[str] = None + commit: Optional[str] = None upstream_diff: Optional[str] = None @classmethod @@ -53,3 +54,47 @@ def to_str(self) -> str: result = [f"{key.replace('_', '-')} {value}" for key, value in commit_header_dict.items() if value] return result + + 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 From 1651747bb9c865789fc04c5caa809cef9767ddda Mon Sep 17 00:00:00 2001 From: Roxana Nicolescu Date: Mon, 8 Jun 2026 18:25:47 +0200 Subject: [PATCH 04/13] check_kernel_commits.py: Use CommitHeader Here not a huge change. upstream commit should be just one, so I removed the useless for. What's nice that we do not need regex anymore. Signed-off-by: Roxana Nicolescu --- check_kernel_commits.py | 64 +++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 37 deletions(-) diff --git a/check_kernel_commits.py b/check_kernel_commits.py index 8aca3fd..f8c67eb 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 +257,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: From c9285e11788fd490fc5ebd8282d4aece3dd57243 Mon Sep 17 00:00:00 2001 From: Roxana Nicolescu Date: Tue, 9 Jun 2026 12:13:37 +0200 Subject: [PATCH 05/13] run_interdiff.py: Use CommitHeader Signed-off-by: Roxana Nicolescu --- run_interdiff.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/run_interdiff.py b/run_interdiff.py index 3bf8ded..a292eb8 100755 --- a/run_interdiff.py +++ b/run_interdiff.py @@ -2,11 +2,12 @@ import argparse import os -import re import subprocess import sys import tempfile +from kt.ktlib.commit_header import CommitHeader + def run_git(repo, args): """Run a git command in the given repository and return its output as a string.""" @@ -52,15 +53,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.""" @@ -178,7 +170,8 @@ 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_hash = commit_header.commit except RuntimeError as e: # Handle errors getting commit information any_differences = True From 2499feb075507d178cdd458c22db57efd60689eb Mon Sep 17 00:00:00 2001 From: Roxana Nicolescu Date: Wed, 10 Jun 2026 14:44:46 +0200 Subject: [PATCH 06/13] jira_pr_check.py: Use CommitHeader instead Signed-off-by: Roxana Nicolescu --- jira_pr_check.py | 43 ++++++--------------------------------- kt/ktlib/commit_header.py | 15 ++++++++++++++ 2 files changed, 21 insertions(+), 37 deletions(-) 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/commit_header.py b/kt/ktlib/commit_header.py index 98419e7..197eea3 100644 --- a/kt/ktlib/commit_header.py +++ b/kt/ktlib/commit_header.py @@ -98,3 +98,18 @@ def make_it_cve_bf(self): 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 From 0608b0dcef01aa13ff1f448236ae3dbdf90c2691 Mon Sep 17 00:00:00 2001 From: Roxana Nicolescu Date: Mon, 15 Jun 2026 15:36:32 +0200 Subject: [PATCH 07/13] kt/ktlib/commit_source.py: Add extra commit source tags commit_author and commit field represent the commit in mainline. If the commit is not in mainline yet, commit value can be `-` or left empty. The fields that show the actual source is something else are: 1. commit_source which could be one of these: arm64: git://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: git://git.kernel.org/pub/scm/linux/kernel/git/arm64/linux.git linux-stable: git://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git 2. commit_source_sha: the actual sha from the remotes presented above. 3. commit_source_author (optional) Introduces upstream_sources.yaml file that contains a list of upstream sources other than mainline. List should be extended to other subsystems soon. It also modifies the following scripts to take into account different commit sources: 1. ciq-cherry-pick.py It receives extra arguments with the commit source and it is responsible of fetching the corresponding upstream remote and cherry picking the commit. 2. check_kernel_commits.py If there is no mainline commit, it does not do any extra checks, since this means the commit is probably in review process (taken from lore). NOTE: This should be revisited later, since we may need checks regardless if the commit is in mainline or not. 3. run_interdiff.py If there is another source commit other than mainline, that is the one used to show the diff. Signed-off-by: Roxana Nicolescu --- check_kernel_commits.py | 4 ++ ciq-cherry-pick.py | 67 +++++++++++++++------------- kt/ktlib/ciq_tag.py | 3 ++ kt/ktlib/commit_header.py | 16 +++++++ kt/ktlib/git_remote.py | 64 ++++++++++++++++++++++++++ run_interdiff.py | 10 ++++- tests/kt/ktlib/test_commit_header.py | 4 +- upstream_sources.yaml | 4 ++ 8 files changed, 140 insertions(+), 32 deletions(-) create mode 100644 kt/ktlib/git_remote.py create mode 100644 upstream_sources.yaml diff --git a/check_kernel_commits.py b/check_kernel_commits.py index f8c67eb..6199b7a 100644 --- a/check_kernel_commits.py +++ b/check_kernel_commits.py @@ -128,6 +128,10 @@ def main(): commit_header = CommitHeader.from_commit_body(commit_body=msg) short_uhash = commit_header.commit[:12] uhash = commit_header.commit + if not uhash or uhash == "-": + print(f"[NOTE]: Mainline commit not present for {pr_commit_desc}, no extra checks for this commit") + continue + # Ensure the referenced commit in the PR actually exists in the upstream ref. exists = hash_exists_in_mainline(args.repo, upstream_ref, uhash) if not exists: diff --git a/ciq-cherry-pick.py b/ciq-cherry-pick.py index 616e8e5..2420325 100644 --- a/ciq-cherry-pick.py +++ b/ciq-cherry-pick.py @@ -23,6 +23,7 @@ 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" @@ -168,12 +169,6 @@ def _manage_commit_message(self, commit_header: CommitHeader, commit_successful: subprocess.run(["cp", MERGE_MSG, MERGE_MSG_BAK], check=True) - # TODO maybe do it earlier, idk - author = CIQ_original_commit_author(repo_path=os.getcwd(), sha=commit_header.commit) - if author is None: - raise RuntimeError(f"Could not find author of commit {commit_header.commit}") - commit_header.commit_author = author - try: with open(MERGE_MSG, "r") as file: original_msg = file.readlines() @@ -193,7 +188,6 @@ def _manage_commit_message(self, commit_header: CommitHeader, commit_successful: new_msg = CIQ_cherry_pick_commit_standardization(lines=original_msg, commit_header=commit_header) - print(f"Cherry Pick New Message for {commit_header.commit}") print(f"\n Original Message located here: {MERGE_MSG_BAK}") try: @@ -245,22 +239,22 @@ def _cherry_pick(self, commit_header: CommitHeader): - You can still see MERGE_MSG for the original message """ - # 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=commit_header.commit) - except RuntimeError as e: - raise RuntimeError(f"Invalid commit SHA {commit_header.commit}: {e}") from e - - commit_header.commit = full_sha - self._check_fixes(sha=full_sha) + _, 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: - CIQ_run_git(repo_path=os.getcwd(), args=["cherry-pick", "-nsx", full_sha]) + 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 + 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 + try: self._manage_commit_message(commit_header=commit_header, commit_successful=commit_successful) except RuntimeError as e: @@ -269,7 +263,7 @@ def _cherry_pick(self, commit_header: CommitHeader): if not commit_successful: error_str = ( - f"[FAILED] git cherry-pick -nsx {full_sha}\n" + 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) @@ -283,18 +277,30 @@ def _cherry_pick_fixes(self, original_commit_header: CommitHeader): 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=self._upstream_ref, hash_=original_commit_header.commit + repo=os.getcwd(), + upstream_ref=self._upstream_ref, + hash_=original_commit_header.commit, # 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 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) + + if args.commit_source_sha and args.commit_source: + upstream_remotes.fetch_remote(remote_name=args.commit_source, repo_dir=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: @@ -303,6 +309,7 @@ def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: for tag in tags: name, value = tag.split(" ") args_dict[name] = value + print(args) return CommitHeader.from_dict(args_dict) @@ -314,7 +321,9 @@ def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: logging.basicConfig(level=logging.INFO) parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) - parser.add_argument("--commit", help="Target SHA1 to cherry-pick", required=True) + parser.add_argument( + "--commit", help="Target SHA1 to cherry-pick", required=False + ) # TODO check if commit exists or not parser.add_argument("--jira", help="Ticket associated to cherry-pick work, comma separated list is supported.") parser.add_argument( "--ciq-tag", @@ -329,6 +338,15 @@ def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: 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", @@ -360,17 +378,6 @@ def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: ) args = parser.parse_args() - # TODO move this to CommitHEADEr maybe - # 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.commit], 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.commit = git_sha_res.stdout.decode("utf-8").strip() - try: cherry_pick_command = CherryPickCommand(args=args) except Exception as e: diff --git a/kt/ktlib/ciq_tag.py b/kt/ktlib/ciq_tag.py index 033b85f..9b3f62a 100644 --- a/kt/ktlib/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 index 197eea3..f910227 100644 --- a/kt/ktlib/commit_header.py +++ b/kt/ktlib/commit_header.py @@ -21,6 +21,9 @@ class CommitHeader: 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 @@ -113,3 +116,16 @@ def all_cves(self) -> list: 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..c5e170e --- /dev/null +++ b/kt/ktlib/git_remote.py @@ -0,0 +1,64 @@ +from dataclasses import dataclass + +import yaml +from git import Repo +from pathlib3x import Path + +UPSTREAM_SOURCE_FILEPATH = Path(__file__).parent.parent.parent.joinpath("upstream_sources.yaml") + + +@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] + remote.fetch() + + # TODO fetch that is not full, depth=1 or sth else?? + + +@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) diff --git a/run_interdiff.py b/run_interdiff.py index a292eb8..3c25b9a 100755 --- a/run_interdiff.py +++ b/run_interdiff.py @@ -7,6 +7,7 @@ import tempfile from kt.ktlib.commit_header import CommitHeader +from kt.ktlib.git_remote import UpstreamRemotes def run_git(repo, args): @@ -151,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: @@ -171,7 +174,12 @@ def main(): msg = get_commit_message(args.repo, sha) commit_header = CommitHeader.from_commit_body(commit_body=msg) - upstream_hash = commit_header.commit + + (upstream_remote, upstream_hash) = commit_header.extract_upstream_name_and_sha() + if upstream_remote: + # Make sure the remote is added and fetched so that interdiff works + print(f"FETCHING REMOTE {upstream_remote}") + upstream_remotes.fetch_remote(remote_name=upstream_remote, repo_dir=args.repo) except RuntimeError as e: # Handle errors getting commit information any_differences = True diff --git a/tests/kt/ktlib/test_commit_header.py b/tests/kt/ktlib/test_commit_header.py index f3936b1..b0659c8 100644 --- a/tests/kt/ktlib/test_commit_header.py +++ b/tests/kt/ktlib/test_commit_header.py @@ -6,7 +6,9 @@ def test_from_commit_body(): jira VULN-181881 cve-bf CVE-2026-31431 commit-author Yucheng Lu -commit 5db6ef9847717329f12c5ea8aba7e9f588a980c0 +commit - +commit-source-sha 5db6ef9847717329f12c5ea8aba7e9f588a980c0 +commit-source centos-stream-9, cryptodev 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 diff --git a/upstream_sources.yaml b/upstream_sources.yaml new file mode 100644 index 0000000..e8ebca4 --- /dev/null +++ b/upstream_sources.yaml @@ -0,0 +1,4 @@ +arm64: git://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: git://git.kernel.org/pub/scm/linux/kernel/git/arm64/linux.git +linux-stable: git://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git From d0ed0d6fdadc57cb0f9bd2330fdaa8c7a0f63f58 Mon Sep 17 00:00:00 2001 From: Roxana Nicolescu Date: Tue, 16 Jun 2026 12:05:13 +0200 Subject: [PATCH 08/13] ciq-cherry-pick: add pick-fixes Signed-off-by: Roxana Nicolescu --- ciq-cherry-pick.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ciq-cherry-pick.py b/ciq-cherry-pick.py index 2420325..e4fe340 100644 --- a/ciq-cherry-pick.py +++ b/ciq-cherry-pick.py @@ -57,6 +57,8 @@ def __init__(self, args): if args.ciq_tag is not None: CIQ_setup_vulns_repo(vulns_repo=args.vulns_dir) + self._pick_fixes = args.pick_fixes + # Helpers def _find_lts_kernel(self, jira_ticket): # TODO this won't be necessary when we move to kt @@ -352,6 +354,12 @@ def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: 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, From b2541a45fefc8588881ecba3852a6f366e984de1 Mon Sep 17 00:00:00 2001 From: Jonathan Maple Date: Fri, 23 Oct 2026 14:49:44 -0400 Subject: [PATCH 09/13] tests: Cover CherryPickCommand, CommitHeader and git_remote; fix exposed bugs The {rnicolescu}_commit_source refactor replaced the flat ciq-cherry-pick functions with CherryPickCommand, CommitHeader and UpstreamRemotes, but the test suite still targeted the deleted API (28 failures) and the new code itself was untested. Test changes: - Rewrite tests/test_ciq_cherry_pick.py against the CherryPickCommand API: _manage_commit_message (header tags, ordering, upstream-diff on conflict, # Conflicts: stripping, commit-source tags), _cherry_pick (mainline, cve-bf, source-sha pick via extract_upstream_name_and_sha, conflict, author resolution), _check_fixes, _update_jira_success/failure, _cherry_pick_fixes (skip guard for source-only picks, cve -> cve-bf swap) and from_argsparse_to_commit_header (tag parsing, sha expansion, remote fetch only when both --commit-source and --commit-source-sha are given). - Expand tests/kt/ktlib/test_commit_header.py: parsing of source-only and mainline bodies, to_str() round trip, cve helpers, extract_upstream_name_and_sha, and a regression test proving the commit-source / commit-source-sha tags can never match the '^commit <40hex>' anchor check_kernel_commits.py validates against. - Add tests/kt/ktlib/test_git_remote.py for UpstreamRemotes/GitRemoteInfo: from_dict, auto-add + fetch, idempotency, unknown remote name, URL-mismatch warning. - Adapt tests/kt/ktlib/test_ciq_helpers.py to the commit_header signature. Fixes the new tests exposed: - commit_header: to_str() returned a list despite '-> str', leaking a Python list repr into every standardized commit message; return the tag lines joined with newlines. - ciq-cherry-pick: map hyphenated tag names (cve-pre, cve-bf) to the underscored CommitHeader fields, which from_dict otherwise drops silently; tolerate multi-word and comma-separated --ciq-tag values (split with maxsplit); skip the Fixes: search when there is no mainline sha (source-only pick); let CherryPickCommand construction errors propagate instead of crashing later on an uninitialized command. - git_remote: warn when fetching an existing remote whose url differs from the configured one instead of silently fetching from the old url. --- ciq-cherry-pick.py | 24 +- kt/ktlib/commit_header.py | 4 +- kt/ktlib/git_remote.py | 10 +- tests/kt/ktlib/test_ciq_helpers.py | 50 +- tests/kt/ktlib/test_commit_header.py | 250 ++++++++-- tests/kt/ktlib/test_git_remote.py | 124 +++++ tests/test_ciq_cherry_pick.py | 663 +++++++++++++++++---------- 7 files changed, 841 insertions(+), 284 deletions(-) create mode 100644 tests/kt/ktlib/test_git_remote.py diff --git a/ciq-cherry-pick.py b/ciq-cherry-pick.py index e4fe340..13c36be 100644 --- a/ciq-cherry-pick.py +++ b/ciq-cherry-pick.py @@ -278,12 +278,19 @@ def _cherry_pick_fixes(self, original_commit_header: CommitHeader): 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_=original_commit_header.commit, # TODO this original_commit_header stuff + 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}") @@ -309,8 +316,10 @@ def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: tags = args.ciq_tag.split(",") # TODO make it smarter maybe idk for tag in tags: - name, value = tag.split(" ") - args_dict[name] = value + name, value = tag.strip().split(" ", 1) + # Tag names are hyphenated (cve-pre, cve-bf, ...); CommitHeader + # fields are underscored. + args_dict[name.replace("-", "_")] = value print(args) @@ -386,10 +395,9 @@ def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: ) args = parser.parse_args() - try: - cherry_pick_command = CherryPickCommand(args=args) - except Exception as e: - print(f"Setting chery pick command went wrong {e}") + # Let construction errors propagate: swallowing them here would crash + # later on an uninitialized cherry_pick_command anyway. + cherry_pick_command = CherryPickCommand(args=args) commit_header = from_argsparse_to_commit_header(args) print(commit_header) diff --git a/kt/ktlib/commit_header.py b/kt/ktlib/commit_header.py index f910227..206f1e7 100644 --- a/kt/ktlib/commit_header.py +++ b/kt/ktlib/commit_header.py @@ -54,9 +54,7 @@ def from_commit_body(cls, commit_body: str) -> CommitHeader: def to_str(self) -> str: commit_header_dict = asdict(self) - result = [f"{key.replace('_', '-')} {value}" for key, value in commit_header_dict.items() if value] - - return result + 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 diff --git a/kt/ktlib/git_remote.py b/kt/ktlib/git_remote.py index c5e170e..afb725e 100644 --- a/kt/ktlib/git_remote.py +++ b/kt/ktlib/git_remote.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +import logging import yaml from git import Repo @@ -32,10 +33,15 @@ def fetch(self, repo_dir: Path = "."): 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() - # TODO fetch that is not full, depth=1 or sth else?? - @dataclass class UpstreamRemotes: 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 index b0659c8..593fd43 100644 --- a/tests/kt/ktlib/test_commit_header.py +++ b/tests/kt/ktlib/test_commit_header.py @@ -1,49 +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" -def test_from_commit_body(): - commit_body = """ +# 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, cryptodev +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. -While crypto_authenc_esn_setauthsize() already rejects explicit -non-zero authsizes in the range 1..3, crypto_authenc_esn_create() -still copied auth->digestsize into inst->alg.maxauthsize without -validating it. The AEAD core then initialized the tfm's default -authsize from that value. - -As a result, selecting an ahash with digest size 1..3, such as -cbcmac(cipher_null), exposed authencesn instances whose default -authsize was invalid even though setauthsize() would have rejected the -same value. AF_ALG could then trigger the ESN tail handling with a -too-short tag and hit an out-of-bounds access. - -Reject authencesn instances whose ahash digest size is in the invalid -non-zero range 1..3 so that no tfm can inherit an unsupported default -authsize. - -Fixes: f15f05b0a5de ("crypto: ccm - switch to separate cbcmac driver") - Cc: stable@kernel.org - Reported-by: Yifan Wu - Reported-by: Juefei Pu - Co-developed-by: Yuan Tan - Signed-off-by: Yuan Tan - Suggested-by: Xin Liu - Tested-by: Yuhang Zheng - Reviewed-by: Eric Biggers Signed-off-by: Yucheng Lu - Signed-off-by: Ren Wei Signed-off-by: Herbert Xu """ - commit_header = CommitHeader.from_commit_body(commit_body=commit_body) - result = commit_header.to_str() - assert result == commit_body + +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..bab6b67 --- /dev/null +++ b/tests/kt/ktlib/test_git_remote.py @@ -0,0 +1,124 @@ +"""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 diff --git a/tests/test_ciq_cherry_pick.py b/tests/test_ciq_cherry_pick.py index 58db7da..8fce38c 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,33 @@ 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, + 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 +50,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 +80,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 +101,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 +142,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 +149,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) + + 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 - 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", - ], + 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. - - Modeled after ciqlts9_6 0df72fa942dc (KVM) and 42796dd9cd76 (net/sched). - """ + def _make_cmd(self, mod, **overrides): + return mod.CherryPickCommand(args=make_args(**overrides)) - 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 +312,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 +328,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 +406,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 +424,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 +440,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 +473,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 +515,273 @@ 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_remote(self, remote_name, repo_dir): + self.fetch_mock(remote_name=remote_name, 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 + assert StubUpstreamRemotes.last_instance.fetch_mock.call_args.kwargs["remote_name"] == "centos-stream-9" + 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_remote(self, remote_name, repo_dir): + self.fetch_mock(remote_name=remote_name, 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 From 3ec154711118f37bc67b7f5564876c57b84ff32b Mon Sep 17 00:00:00 2001 From: Jonathan Maple Date: Fri, 23 Oct 2026 16:04:15 -0400 Subject: [PATCH 10/13] ciq-cherry-pick: accept --sha as deprecated alias for --commit The commit_source refactor renamed the target-sha flag --sha to --commit, which breaks existing callers - notably kernel-tools' cve_remediation_automation.py, which still invokes 'ciq-cherry-pick.py --sha --ticket ... --ciq-tag ...' and would fail with 'unrecognized arguments: --sha'. Accept both flags naming the same internal parameter: - --sha is parsed into its own dest and folded into args.commit by resolve_sha_alias() right after parse_args() - passing both with conflicting values is an argparse error - passing both with the same value is accepted --help marks --sha as a deprecated alias. --- ciq-cherry-pick.py | 32 ++++++++++- tests/test_ciq_cherry_pick.py | 103 ++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/ciq-cherry-pick.py b/ciq-cherry-pick.py index 13c36be..467e161 100644 --- a/ciq-cherry-pick.py +++ b/ciq-cherry-pick.py @@ -301,6 +301,25 @@ def _cherry_pick_fixes(self, original_commit_header: CommitHeader): 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 + + if args.commit and args.commit != sha: + parser.error("--commit and --sha are aliases; specify only one of them") + + args.commit = sha + args.sha = None + + def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: upstream_remotes = UpstreamRemotes.from_yaml() if args.commit and args.commit != "-": @@ -333,8 +352,17 @@ def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) parser.add_argument( - "--commit", help="Target SHA1 to cherry-pick", required=False + "--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", help="Ticket associated to cherry-pick work, comma separated list is supported.") parser.add_argument( "--ciq-tag", @@ -395,6 +423,8 @@ def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: ) args = parser.parse_args() + resolve_sha_alias(args=args, parser=parser) + # Let construction errors propagate: swallowing them here would crash # later on an uninitialized cherry_pick_command anyway. cherry_pick_command = CherryPickCommand(args=args) diff --git a/tests/test_ciq_cherry_pick.py b/tests/test_ciq_cherry_pick.py index 8fce38c..fba93e0 100644 --- a/tests/test_ciq_cherry_pick.py +++ b/tests/test_ciq_cherry_pick.py @@ -27,6 +27,7 @@ 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", @@ -785,3 +786,105 @@ def fetch_remote(self, remote_name, repo_dir): 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" From 78c486546f98f25b4433f803cc7dcb654736c552 Mon Sep 17 00:00:00 2001 From: Jonathan Maple Date: Fri, 23 Oct 2026 17:07:55 -0400 Subject: [PATCH 11/13] git_remote: fetch_for_commit_source resolves branch-style commit sources The commit-source tag is a provenance label and can now be either an upstream_sources.yaml remote name (e.g. centos-stream-9) or a stable branch name (e.g. linux-6.12.y). Both consumers of that tag treated it strictly as a remote-name key: - ciq-cherry-pick.py fetch_remote() would KeyError on a branch name - run_interdiff.py likewise, flagging every branch-provenance PR commit as an ERROR during CI validation Add UpstreamRemotes.fetch_for_commit_source(commit_source, sha, repo_dir): 1. sha already resolvable locally -> no-op (no network; the CI fast path when the workflow pre-fetched the branch) 2. commit_source is a yaml key -> fetch that remote (unchanged behavior) 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 with actionable guidance Both call sites switch to the helper. run_interdiff additionally only fetches when both source name and sha are present. --- ciq-cherry-pick.py | 5 +- kt/ktlib/git_remote.py | 54 ++++++++++++++++ run_interdiff.py | 11 ++-- tests/kt/ktlib/test_git_remote.py | 104 ++++++++++++++++++++++++++++++ tests/test_ciq_cherry_pick.py | 12 ++-- 5 files changed, 176 insertions(+), 10 deletions(-) diff --git a/ciq-cherry-pick.py b/ciq-cherry-pick.py index 467e161..8e6fe45 100644 --- a/ciq-cherry-pick.py +++ b/ciq-cherry-pick.py @@ -6,6 +6,7 @@ import sys import traceback from datetime import datetime +from pathlib import Path import git @@ -326,7 +327,9 @@ def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: args.commit = CIQ_get_full_hash(repo=os.getcwd(), short_hash=args.commit) if args.commit_source_sha and args.commit_source: - upstream_remotes.fetch_remote(remote_name=args.commit_source, repo_dir=os.getcwd()) + 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) diff --git a/kt/ktlib/git_remote.py b/kt/ktlib/git_remote.py index afb725e..4bb42fb 100644 --- a/kt/ktlib/git_remote.py +++ b/kt/ktlib/git_remote.py @@ -3,11 +3,21 @@ 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: """ @@ -68,3 +78,47 @@ def from_dict(cls, data: 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 3c25b9a..a773d74 100755 --- a/run_interdiff.py +++ b/run_interdiff.py @@ -176,10 +176,13 @@ def main(): commit_header = CommitHeader.from_commit_body(commit_body=msg) (upstream_remote, upstream_hash) = commit_header.extract_upstream_name_and_sha() - if upstream_remote: - # Make sure the remote is added and fetched so that interdiff works - print(f"FETCHING REMOTE {upstream_remote}") - upstream_remotes.fetch_remote(remote_name=upstream_remote, repo_dir=args.repo) + 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_git_remote.py b/tests/kt/ktlib/test_git_remote.py index bab6b67..3f9c0d4 100644 --- a/tests/kt/ktlib/test_git_remote.py +++ b/tests/kt/ktlib/test_git_remote.py @@ -122,3 +122,107 @@ def test_fetch_warns_on_url_mismatch_existing_remote(local_repo, bare_remote, ca # 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 fba93e0..74ee2df 100644 --- a/tests/test_ciq_cherry_pick.py +++ b/tests/test_ciq_cherry_pick.py @@ -747,8 +747,8 @@ def __init__(self): def from_yaml(cls): return cls() - def fetch_remote(self, remote_name, repo_dir): - self.fetch_mock(remote_name=remote_name, repo_dir=repo_dir) + 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) @@ -756,7 +756,9 @@ def fetch_remote(self, remote_name, repo_dir): header = mod.from_argsparse_to_commit_header(args) assert StubUpstreamRemotes.last_instance.fetch_mock.call_count == 1 - assert StubUpstreamRemotes.last_instance.fetch_mock.call_args.kwargs["remote_name"] == "centos-stream-9" + 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 @@ -775,8 +777,8 @@ def __init__(self): def from_yaml(cls): return cls() - def fetch_remote(self, remote_name, repo_dir): - self.fetch_mock(remote_name=remote_name, repo_dir=repo_dir) + 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) From ea60c60f9d7c6d53c9bf786c21f99a34dc1fe4b3 Mon Sep 17 00:00:00 2001 From: Jonathan Maple Date: Fri, 23 Oct 2026 17:08:09 -0400 Subject: [PATCH 12/13] upstream_sources.yaml: fix centos-stream-10 url, use https for kernel.org centos-stream-10 pointed at the arm64 kernel tree (copy-paste bug); point it at the CentOS Stream 10 kernel gitlab instead. kernel.org entries used git://, an unauthenticated transport whose port 9418 is commonly blocked by firewalls; switch to https. --- upstream_sources.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/upstream_sources.yaml b/upstream_sources.yaml index e8ebca4..cf8aa46 100644 --- a/upstream_sources.yaml +++ b/upstream_sources.yaml @@ -1,4 +1,4 @@ -arm64: git://git.kernel.org/pub/scm/linux/kernel/git/arm64/linux.git +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: git://git.kernel.org/pub/scm/linux/kernel/git/arm64/linux.git -linux-stable: git://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.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 From ae2fe882029b6fcef03d6c74ac5227aaced3bd8c Mon Sep 17 00:00:00 2001 From: Jonathan Maple Date: Fri, 23 Oct 2026 18:43:00 -0400 Subject: [PATCH 13/13] ciq-cherry-pick: accept --ticket as deprecated alias for --jira The commit_source refactor renamed --ticket to --jira, breaking existing callers - kernel-tools' cve_remediation_automation.py still passes --ticket VULN-xxx and died with 'unrecognized arguments' (invisible to its logs, which discard stderr). Same pattern as the --sha alias: separate dest folded by resolve_ticket_alias() right after parse_args(); conflicting values are an argparse error; same value is fine. --- ciq-cherry-pick.py | 28 ++++++++++- tests/test_ciq_cherry_pick.py | 88 +++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/ciq-cherry-pick.py b/ciq-cherry-pick.py index 8e6fe45..acd9564 100644 --- a/ciq-cherry-pick.py +++ b/ciq-cherry-pick.py @@ -321,6 +321,23 @@ def resolve_sha_alias(args: argparse.Namespace, parser: argparse.ArgumentParser) args.sha = None +def resolve_ticket_alias(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None: + """Fold the deprecated --ticket flag into --jira. + + 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 + + if args.jira and args.jira != ticket: + parser.error("--jira and --ticket are aliases; specify only one of them") + + args.jira = ticket + args.ticket = None + + def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: upstream_remotes = UpstreamRemotes.from_yaml() if args.commit and args.commit != "-": @@ -366,7 +383,15 @@ def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: required=False, help="Deprecated alias for --commit (kept for existing callers, e.g. kernel-tools cve_remediation_automation)", ) - parser.add_argument("--jira", help="Ticket associated to cherry-pick work, comma separated list is supported.") + 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" @@ -427,6 +452,7 @@ def from_argsparse_to_commit_header(args: argparse.Namespace) -> CommitHeader: args = parser.parse_args() resolve_sha_alias(args=args, parser=parser) + resolve_ticket_alias(args=args, parser=parser) # Let construction errors propagate: swallowing them here would crash # later on an uninitialized cherry_pick_command anyway. diff --git a/tests/test_ciq_cherry_pick.py b/tests/test_ciq_cherry_pick.py index 74ee2df..9602975 100644 --- a/tests/test_ciq_cherry_pick.py +++ b/tests/test_ciq_cherry_pick.py @@ -890,3 +890,91 @@ def test_sha_alias_end_to_end_header(self, cherry_pick_env): 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"