script/ptl-tool: add --qe-label option for automated QA workflows

Introduce the `--qe-label <pr-label>` option to streamline daily integration
testing and Redmine QA tracking for backports.

Using `--qe-label` automatically implies `--integration` and `--pr-label`,
and manages Redmine QA tracking tickets via custom field 45 (Ceph PR Label):

* Searches for open QA tickets matching the specified PR label and target
  release branch.
* If exactly one open ticket is found, prompts the user to either update
  the existing ticket or create a new one.
* If no open ticket is found, automatically defaults to creating a new
  QA ticket with the label stored in its metadata.
* Adds pre-flight validation to ensure all labeled PRs target the exact
  same base branch, preventing ambiguous or mixed-release merges.
* Halts execution if multiple ambiguous open QA tickets exist for the
  same label and release branch.

Signed-off-by: Patrick Donnelly <pdonnell@ibm.com>
This commit is contained in:
Patrick Donnelly 2026-07-11 15:02:23 -04:00
parent aaeb264d29
commit 6f306f79e5
No known key found for this signature in database
GPG Key ID: 053758C0A8A3CE2F

View File

@ -1832,7 +1832,7 @@ def build_branch(args):
G = git.Repo(args.git)
R = None
if args.create_qa or args.update_qa or args.audit or args.final_merge:
if args.create_qa or args.update_qa or args.audit or args.final_merge or args.qe_label:
log.info("connecting to %s", REDMINE_ENDPOINT)
R = Redmine(REDMINE_ENDPOINT, username=REDMINE_USER, key=REDMINE_API_KEY)
log.debug("connected")
@ -1857,18 +1857,32 @@ def build_branch(args):
prs.append(n)
log.info("Will merge PRs: {}".format(prs))
# PRE-FLIGHT: Auto-detect base from the first PR if necessary
if prs and base is None:
first_pr = prs[0]
detected_base = get_pr_info(session, first_pr).get("base", {}).get("ref")
# PRE-FLIGHT: Validate base consistency and auto-detect base from PRs if necessary
if prs and (base is None or args.qe_label or args.integration):
bases_seen = {}
for pr_num in prs:
ref = get_pr_info(session, pr_num).get("base", {}).get("ref")
if not ref:
raise SystemExit(f"Could not determine base branch for PR #{pr_num}")
bases_seen[pr_num] = ref
if detected_base:
log.info(f"Auto-detected target base from PR #{first_pr}: {detected_base}")
unique_bases = set(bases_seen.values())
if len(unique_bases) > 1:
log.error("PRs target multiple different base branches! Ambiguity is not allowed.")
for p_num, b_ref in bases_seen.items():
log.error(f" PR #{p_num} -> targets '{b_ref}'")
sys.exit(1)
detected_base = list(unique_bases)[0]
if base is None:
log.info(f"Auto-detected target base from PRs: {detected_base}")
base = detected_base
if args.merge_branch_name is False:
merge_branch_name = detected_base
else:
raise SystemExit(f"Could not auto-detect base for PR #{first_pr}. Use hard-coded --base")
elif base != detected_base:
log.error(f"Provided --base '{base}' does not match the target base branch '{detected_base}' of the PRs!")
sys.exit(1)
if args.integration:
if not base or base == 'HEAD':
@ -1881,6 +1895,56 @@ def build_branch(args):
args.always_fetch = True
args.skip_conflict_check = True
if args.qe_label and not args.update_qa:
log.info(f"Searching Redmine for open QA tickets matching Ceph PR Label '{args.qe_label}'...")
filters = {
f"cf_{REDMINE_CUSTOM_FIELD_ID_CEPH_PR_LABEL}": args.qe_label,
"status_id": "open"
}
open_tickets = list(R.issue.filter(**filters))
matching_tickets = []
for t in open_tickets:
t_release = get_custom_field(t, REDMINE_CUSTOM_FIELD_ID_QA_RELEASE)
if t_release and t_release != base:
log.error(f"Open QA ticket #{t.id} ({REDMINE_ENDPOINT}/issues/{t.id}) uses label '{args.qe_label}' but targets release '{t_release}' (expected '{base}')! Release mismatch not allowed.")
sys.exit(1)
matching_tickets.append(t)
if len(matching_tickets) > 1:
log.error(f"Ambiguity error: Found {len(matching_tickets)} open QA tickets matching label '{args.qe_label}' for release '{base}':")
for t in matching_tickets:
log.error(f" #{t.id}: {t.subject} ({REDMINE_ENDPOINT}/issues/{t.id})")
sys.exit(1)
elif len(matching_tickets) == 1:
t = matching_tickets[0]
t_url = f"{REDMINE_ENDPOINT}/issues/{t.id}"
print(f"\nFound existing open QA ticket: #{t.id} - {t.subject}")
print(f"Link: {t_url}")
while True:
ans = input("Do you want to update this existing QA ticket? [y/n/o/q] (y=update existing, n=create new, o=open in browser, q=quit): ").strip().lower()
if ans == 'y':
args.update_qa = t.id
log.info(f"Will update existing QA ticket #{t.id}.")
break
elif ans == 'n':
log.info("Will create a new QA ticket instead.")
args.create_qa = True
break
elif ans == 'o':
open_in_browser([t_url])
print(f"Opened {t_url} in browser.")
elif ans == 'q':
log.info("Exiting script.")
sys.exit(0)
elif ans == '':
continue
else:
print("Invalid choice. Please enter y, n, o, or q.")
else:
log.info("No open QA tickets found for this label. Will create a new QA ticket.")
args.create_qa = True
if args.credits:
try:
c = resolve_ref(G, 'main', BASE_REMOTE_URL, args.always_fetch)
@ -2156,6 +2220,7 @@ def main():
group = parser.add_argument_group('GitHub PR Options')
group.add_argument('--label', dest='label', action='store', default=default_label, help='label PRs for testing')
group.add_argument('--pr-label', dest='pr_label', action='store', help='source PRs to merge via label')
group.add_argument('--qe-label', dest='qe_label', action='store', help='variant of --integration that merges PRs by label and creates/updates an associated Redmine QA ticket')
group = parser.add_argument_group('Branch Control Options')
group.add_argument('--always-fetch', dest='always_fetch', action='store_true', help='always fetch commits from remote (bypass local cache)')
@ -2211,6 +2276,13 @@ def main():
args = parser.parse_args(argv)
if args.qe_label:
if args.ci_mode:
log.error("--qe-label cannot be used with --ci-mode at this time.")
sys.exit(1)
args.integration = True
args.pr_label = args.qe_label
# Make --audit-label redundant when --ci-mode is invoked
if args.ci_mode and not args.audit_label:
args.audit_label = parse_audit_labels("releng-audit-pass,releng-audit-fail")
@ -2272,7 +2344,7 @@ def main():
log.error("or set the PTL_TOOL_GITHUB_TOKEN environment variable.")
sys.exit(1)
if args.create_qa or args.update_qa or args.audit or args.final_merge:
if args.create_qa or args.update_qa or args.audit or args.final_merge or args.qe_label:
if Redmine is None:
log.error("redmine library is not available so cannot create qa tracker ticket or audit")
sys.exit(1)