mach_commands.py (36512B)
1 # This Source Code Form is subject to the terms of the Mozilla Public 2 # License, v. 2.0. If a copy of the MPL was not distributed with this 3 # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 5 import io 6 import os 7 import re 8 import shutil 9 import subprocess 10 import sys 11 import tempfile 12 import time 13 from datetime import datetime, timedelta, timezone 14 from enum import Enum 15 from pathlib import Path 16 from urllib.parse import urljoin, urlparse 17 from zipfile import ZipFile 18 19 import requests 20 import yaml 21 from colorama import Fore, Style 22 from mach.decorators import ( 23 Command, 24 CommandArgument, 25 SubCommand, 26 ) 27 from mozfile import json 28 29 import taskcluster 30 31 # The glean parser module dependency is in a different folder, so we add it to our path. 32 sys.path.append( 33 str( 34 Path( 35 "toolkit", "components", "glean", "build_scripts", "glean_parser_ext" 36 ).absolute() 37 ) 38 ) 39 WEBEXT_METRICS_PATH = Path("browser", "extensions", "newtab", "webext-glue", "metrics") 40 sys.path.append(str(WEBEXT_METRICS_PATH.absolute())) 41 import glean_utils 42 from run_glean_parser import parse_with_options 43 44 FIREFOX_L10N_REPO = "https://github.com/mozilla-l10n/firefox-l10n.git" 45 FLUENT_FILE = "newtab.ftl" 46 WEBEXT_LOCALES_PATH = Path("browser", "extensions", "newtab", "webext-glue", "locales") 47 48 LOCAL_EN_US_PATH = Path("browser", "locales", "en-US", "browser", "newtab", FLUENT_FILE) 49 COMPARE_TOOL_PATH = Path( 50 "third_party", "python", "moz_l10n", "moz", "l10n", "bin", "compare.py" 51 ) 52 REPORT_PATH = Path(WEBEXT_LOCALES_PATH, "locales-report.json") 53 REPORT_LEFT_JUSTIFY_CHARS = 15 54 FLUENT_FILE_ANCESTRY = Path("browser", "newtab") 55 SUPPORTED_LOCALES_PATH = Path(WEBEXT_LOCALES_PATH, "supported-locales.json") 56 57 # We query whattrainisitnow.com to get some key dates for both beta and 58 # release in order to compute whether or not strings have been available on 59 # the beta channel long enough to consider falling back (currently, that's 60 # 3 weeks of time on the beta channel). 61 BETA_SCHEDULE_QUERY = "https://whattrainisitnow.com/api/release/schedule/?version=beta" 62 RELEASE_SCHEDULE_QUERY = ( 63 "https://whattrainisitnow.com/api/release/schedule/?version=release" 64 ) 65 BETA_FALLBACK_THRESHOLD = timedelta(weeks=3) 66 TASKCLUSTER_ROOT_URL = "https://firefox-ci-tc.services.mozilla.com" 67 BEETMOVER_TASK_NAME = "beetmover-newtab" 68 XPI_NAME = "newtab.xpi" 69 BEETMOVER_ARTIFACT_PATH = f"public/build/{XPI_NAME}" 70 ARCHIVE_ROOT_PATH = "https://ftp.mozilla.org" 71 72 73 class YamlType(Enum): 74 METRICS = "metrics" 75 PINGS = "pings" 76 77 78 @Command( 79 "newtab", 80 category="misc", 81 description="Run a command for the newtab built-in addon", 82 virtualenv_name="newtab", 83 ) 84 def newtab(command_context): 85 """ 86 Desktop New Tab build and update utilities. 87 """ 88 command_context._sub_mach(["help", "newtab"]) 89 return 1 90 91 92 def run_mach(command_context, cmd, **kwargs): 93 return command_context._mach_context.commands.dispatch( 94 cmd, command_context._mach_context, **kwargs 95 ) 96 97 98 @SubCommand( 99 "newtab", 100 "watch", 101 description="Invokes npm run watchmc and mach watch simultaneously for auto-building and bundling of compiled newtab code.", 102 ) 103 def watch(command_context): 104 processes = [] 105 106 try: 107 p1 = subprocess.Popen([ 108 "./mach", 109 "npm", 110 "run", 111 "watchmc", 112 "--prefix=browser/extensions/newtab", 113 ]) 114 p2 = subprocess.Popen(["./mach", "watch"]) 115 processes.extend([p1, p2]) 116 print("Watching subprocesses started. Press Ctrl-C to terminate them.") 117 118 while True: 119 time.sleep(1) 120 121 except KeyboardInterrupt: 122 print("\nSIGINT received. Terminating subprocesses...") 123 for p in processes: 124 p.terminate() 125 for p in processes: 126 p.wait() 127 print("All watching subprocesses terminated.") 128 129 # Rebundle to avoid having all of the sourcemaps stick around. 130 run_mach( 131 command_context, 132 "npm", 133 args=["run", "bundle", "--prefix=browser/extensions/newtab"], 134 ) 135 136 137 @SubCommand( 138 "newtab", 139 "update-locales", 140 description="Update the locales snapshot.", 141 virtualenv_name="newtab", 142 ) 143 def update_locales(command_context): 144 try: 145 os.mkdir(WEBEXT_LOCALES_PATH) 146 except FileExistsError: 147 pass 148 149 # Step 1: We download the latest reckoning of strings from firefox-l10n 150 print("Cloning the latest HEAD of firefox-l10n repository") 151 with tempfile.TemporaryDirectory() as clone_dir: 152 subprocess.check_call([ 153 "git", 154 "clone", 155 "--depth=1", 156 FIREFOX_L10N_REPO, 157 clone_dir, 158 ]) 159 # Step 2: Get some metadata about what we just pulled down - 160 # specifically, the revision. 161 revision = subprocess.check_output( 162 ["git", "rev-parse", "HEAD"], 163 cwd=str(clone_dir), 164 universal_newlines=True, 165 ).strip() 166 167 # Step 3: Recursively find all files matching the filename for our 168 # FLUENT_FILE, and copy them into WEBEXT_LOCALES_PATH/AB_CD/FLUENT_FILE 169 root_dir = Path(clone_dir) 170 fluent_file_matches = list(root_dir.rglob(FLUENT_FILE)) 171 for fluent_file_abs_path in fluent_file_matches: 172 relative_path = fluent_file_abs_path.relative_to(root_dir) 173 # The first element of the path is the locale code, which we want 174 # to recreate under WEBEXT_LOCALES_PATH 175 locale = relative_path.parts[0] 176 destination_file = WEBEXT_LOCALES_PATH.joinpath( 177 locale, FLUENT_FILE_ANCESTRY, FLUENT_FILE 178 ) 179 destination_file.parent.mkdir(parents=True, exist_ok=True) 180 shutil.copy2(fluent_file_abs_path, destination_file) 181 182 # Now clean up the temporary directory. 183 shutil.rmtree(clone_dir) 184 185 # Step 4: Now copy the local version of FLUENT_FILE in LOCAL_EN_US_PATH 186 # into WEBEXT_LOCALES_PATH/en-US/FLUENT_FILE 187 print(f"Cloning local en-US copy of {FLUENT_FILE}") 188 dest_en_ftl_path = WEBEXT_LOCALES_PATH.joinpath( 189 "en-US", FLUENT_FILE_ANCESTRY, FLUENT_FILE 190 ) 191 dest_en_ftl_path.parent.mkdir(parents=True, exist_ok=True) 192 shutil.copy2(LOCAL_EN_US_PATH, dest_en_ftl_path) 193 194 # Step 4.5: Now compute the commit dates of each of the strings inside of 195 # LOCAL_EN_US_PATH. 196 print("Computing local message commit dates…") 197 message_dates = get_message_dates(LOCAL_EN_US_PATH) 198 199 # Step 5: Now compare that en-US Fluent file with all of the ones we just 200 # cloned and create a report with how many strings are still missing. 201 print("Generating localization report…") 202 203 source_ftl_path = WEBEXT_LOCALES_PATH.joinpath("en-US") 204 paths = list(WEBEXT_LOCALES_PATH.rglob(FLUENT_FILE)) 205 206 # There are 2 parent folders of each FLUENT_FILE (see FLUENT_FILE_ANCESTRY), 207 # and we want to get at the locale folder root for our comparison. 208 ANCESTRY_LENGTH = 2 209 210 # Get the full list of supported locales that we just pulled down 211 supported_locales = sorted([path.parents[ANCESTRY_LENGTH].name for path in paths]) 212 path_strs = [path.parents[ANCESTRY_LENGTH].as_posix() for path in paths] 213 214 # Verbosity on the compare.py tool appears to be a count value, which is 215 # incremented for each -v flag. We want an elevated verbosity so that we 216 # get back 217 verbosity = ["-v", "-v"] 218 # A bug in compare.py means that the source folder must be passed in as 219 # an absolute path. 220 source = ["--source=%s" % source_ftl_path.absolute().as_posix()] 221 other_flags = ["--json"] 222 223 # The moz.l10n compare tool is currently designed to be invoked from the 224 # command line interface. We'll use subprocess to invoke it and capture 225 # its output. 226 python = command_context.virtualenv_manager.python_path 227 228 def on_line(line): 229 locales = json.loads(line) 230 231 # The compare tool seems to produce non-deterministic ordering of 232 # missing message IDs, which makes reasoning about changes to the 233 # report JSON difficult. We sort each locales list of missing message 234 # IDs alphabetically. 235 REPORT_FILE_PATH = f"browser/newtab/{FLUENT_FILE}" 236 for locale, locale_data in locales.items(): 237 missing = locale_data.get("missing", None) 238 if isinstance(missing, dict): 239 entries = missing.get(REPORT_FILE_PATH, None) 240 if isinstance(entries, list): 241 entries.sort() 242 243 report = { 244 "locales": locales, 245 "meta": { 246 "repository": FIREFOX_L10N_REPO, 247 "revision": revision, 248 "updated": datetime.utcnow().isoformat(), 249 }, 250 "message_dates": message_dates, 251 } 252 with open(REPORT_PATH, "w") as file: 253 json.dump(report, file, indent=2, sort_keys=True) 254 display_report(report) 255 print("Wrote report to %s" % REPORT_PATH) 256 257 command_context.run_process( 258 [python, str(COMPARE_TOOL_PATH)] + other_flags + source + verbosity + path_strs, 259 pass_thru=False, 260 line_handler=on_line, 261 ) 262 263 print("Writing supported locales to %s" % SUPPORTED_LOCALES_PATH) 264 with open(SUPPORTED_LOCALES_PATH, "w") as file: 265 json.dump(supported_locales, file, indent=2) 266 267 print("Done") 268 269 270 @SubCommand( 271 "newtab", 272 "locales-report", 273 description="Parses the current locales-report.json and produces something human readable.", 274 virtualenv_name="newtab", 275 ) 276 @CommandArgument( 277 "--details", default=None, help="Which locale to pull up details about" 278 ) 279 def locales_report(command_context, details): 280 with open(REPORT_PATH) as file: 281 report = json.load(file) 282 display_report(report, details) 283 284 285 def get_message_dates(fluent_file_path): 286 """Computes the landing dates of strings in fluent_file_path. 287 288 This is returned as a dict of Fluent message names mapped 289 to ISO-formatted dates for their landings. 290 """ 291 result = subprocess.run( 292 ["git", "blame", "--line-porcelain", fluent_file_path], 293 stdout=subprocess.PIPE, 294 text=True, 295 check=True, 296 ) 297 298 pattern = re.compile(r"^([a-z-]+[^\s]+) ") 299 entries = {} 300 entry = {} 301 302 for line in result.stdout.splitlines(): 303 if line.startswith("\t"): 304 code = line[1:] 305 match = pattern.match(code) 306 if match: 307 key = match.group(1) 308 timestamp = int(entry.get("committer-time", 0)) 309 commit_time = datetime.fromtimestamp(timestamp) 310 # Only store the first time it was introduced (which blame gives us) 311 entries[key] = commit_time.isoformat() 312 entry = {} 313 elif " " in line: 314 key, val = line.split(" ", 1) 315 entry[key] = val 316 317 return entries 318 319 320 def get_date_manually(): 321 """Requests a date from the user in yyyy/mm/dd format. 322 323 This will loop until a valid date is computed. Returns a datetime. 324 """ 325 while True: 326 try: 327 typed_chars = input("Enter date manually (yyyy/mm/dd): ") 328 manual_date = datetime.strptime(typed_chars, "%Y/%m/%d") 329 return manual_date 330 except ValueError: 331 print("Invalid date format. Please use yyyy/mm/dd.") 332 333 334 def display_report(report, details=None): 335 """Displays a report about the current newtab localization state. 336 337 This report is calculated using the REPORT_PATH file generated 338 via the update-locales command, along with the merge-to-beta 339 dates of the most recent beta and release versions of the browser, 340 as well as the current date. 341 342 Details about a particular locale can be requested via the details 343 argument. 344 """ 345 print("New Tab locales report") 346 # We need some key dates to determine which strings are currently awaiting 347 # translations on the beta channel, and which strings are just missing 348 # (where missing means that they've been available for translation on the 349 # beta channel for more than the BETA_FALLBACK_THRESHOLD, and are still 350 # not available). 351 # 352 # We need to get the last merge date of the current beta, and the merge 353 # date of the current release. 354 try: 355 response = requests.get(BETA_SCHEDULE_QUERY, timeout=10) 356 response.raise_for_status() 357 beta_merge_date = datetime.fromisoformat(response.json()["merge_day"]) 358 except (requests.RequestException, requests.HTTPError): 359 print(f"Failed to compute last beta merge day for {FLUENT_FILE}.") 360 beta_merge_date = get_date_manually() 361 362 beta_merge_date = beta_merge_date.replace(tzinfo=timezone.utc) 363 print(f"Beta date: {beta_merge_date}") 364 365 # The release query needs to be different because the endpoint doesn't 366 # actually tell us the merge-to-beta date for the version on the release 367 # channel. We guesstimate it by getting at the build date for the first 368 # beta of that version, and finding the last prior Monday. 369 try: 370 response = requests.get(RELEASE_SCHEDULE_QUERY, timeout=10) 371 response.raise_for_status() 372 release_merge_date = datetime.fromisoformat(response.json()["beta_1"]) 373 # weekday() defaults to Monday. 374 release_merge_date = release_merge_date - timedelta( 375 days=release_merge_date.weekday() 376 ) 377 except (requests.RequestException, requests.HTTPError): 378 print( 379 f"Failed to compute the merge-to-beta day the current release for {FLUENT_FILE}." 380 ) 381 release_merge_date = get_date_manually() 382 383 release_merge_date = release_merge_date.replace(tzinfo=timezone.utc) 384 print(f"Release merge-to-beta date: {release_merge_date}") 385 386 # These two dates will be used later on when we start calculating which 387 # untranslated strings should be considered "pending" (we're still waiting 388 # for them to be on beta for at least 3 weeks), and which should be 389 # considered "missing" (they've been on beta for more than 3 weeks and 390 # still aren't translated). 391 392 meta = report["meta"] 393 message_date_strings = report["message_dates"] 394 # Convert each message date into a datetime object 395 message_dates = { 396 key: datetime.fromisoformat(value).replace(tzinfo=timezone.utc) 397 for key, value in message_date_strings.items() 398 } 399 400 print(f"Locales last updated: {meta['updated']}") 401 print(f"From {meta['repository']} - revision: {meta['revision']}") 402 print("------") 403 if details: 404 if details not in report["locales"]: 405 print(f"Unknown locale '{details}'") 406 return 407 sorted_locales = [details] 408 else: 409 sorted_locales = sorted(report["locales"].keys(), key=lambda x: x.lower()) 410 for locale in sorted_locales: 411 print(Style.RESET_ALL, end="") 412 if report["locales"][locale]["missing"]: 413 missing_translations = report["locales"][locale]["missing"][ 414 str(FLUENT_FILE_ANCESTRY.joinpath(FLUENT_FILE)) 415 ] 416 # For each missing string, see if any of them have been in the 417 # en-US locale for less than BETA_FALLBACK_THRESHOLD. If so, these 418 # strings haven't been on the beta channel long enough to consider 419 # falling back. We're still awaiting translations on them. 420 total_pending_translations = 0 421 total_missing_translations = 0 422 for missing_translation in missing_translations: 423 message_id = missing_translation.split(".")[0] 424 if message_dates[message_id] < release_merge_date: 425 # Anything landed prior to the most recent release 426 # merge-to-beta date has clearly been around long enough 427 # to be translated. This is a "missing" string. 428 total_missing_translations = total_missing_translations + 1 429 if details: 430 print( 431 Fore.YELLOW 432 + f"Missing: {message_dates[message_id]}: {message_id}" 433 ) 434 elif message_dates[message_id] < beta_merge_date and ( 435 datetime.now(timezone.utc) - beta_merge_date 436 > BETA_FALLBACK_THRESHOLD 437 ): 438 # Anything that landed after the release merge to beta, but 439 # before the most recent merge to beta, we'll consider its 440 # age to be the beta_merge_date, rather than the 441 # message_dates entry. If we compare the beta_merge_date 442 # with the current time and see that BETA_FALLBACK_THRESHOLD 443 # has passed, then the string is missing. 444 total_missing_translations = total_missing_translations + 1 445 if details: 446 print( 447 Fore.YELLOW 448 + f"Missing: {message_dates[message_id]}: {message_id}" 449 ) 450 else: 451 # Otherwise, this string has not been on beta long enough 452 # to have been translated. This is a pending string. 453 total_pending_translations = total_pending_translations + 1 454 if details: 455 print( 456 Fore.RED 457 + f"Pending: {message_dates[message_id]}: {message_id}" 458 ) 459 460 if total_pending_translations > 10: 461 color = Fore.RED 462 else: 463 color = Fore.YELLOW 464 print( 465 color 466 + f"{locale.ljust(REPORT_LEFT_JUSTIFY_CHARS)}{total_pending_translations} pending translations, {total_missing_translations} missing translations" 467 ) 468 469 else: 470 print( 471 Fore.GREEN 472 + f"{locale.ljust(REPORT_LEFT_JUSTIFY_CHARS)}0 missing translations" 473 ) 474 print(Style.RESET_ALL, end="") 475 476 477 @SubCommand( 478 "newtab", 479 "channel-metrics-diff", 480 description="Compares and produces a JSON diff between local NewTab metrics and pings, and the specified channel.", 481 virtualenv_name="newtab", 482 ) 483 @CommandArgument( 484 "--channel", 485 default="release", 486 choices=["beta", "release"], 487 help="Which channel should be used to compare NewTab metrics and pings YAML", 488 ) 489 def channel_metrics_diff(command_context, channel): 490 """ 491 Fetch main and a comparison branch (beta or release) metrics.yaml, compute only the new metrics, 492 and process as before. To run use: ./mach newtab channel-metrics-diff --channel [beta|release] 493 This will print YAML-formatted output to stdout, showing the differences in newtab metrics and pings. 494 """ 495 METRICS_LOCAL_YAML_PATH = Path("browser", "components", "newtab", "metrics.yaml") 496 PINGS_LOCAL_YAML_PATH = Path("browser", "components", "newtab", "pings.yaml") 497 498 try: 499 # Get Firefox version from version.txt 500 version_file = Path("browser", "config", "version.txt") 501 if not version_file.exists(): 502 print("Error: version.txt not found") 503 return 1 504 505 with open(version_file) as f: 506 # Extract just the major version number (e.g., "141" from "141.0a1") 507 firefox_version = int(f.read().strip().split(".")[0]) 508 509 # Adjust version number based on channel 510 if channel == "beta": 511 firefox_version -= 1 512 elif channel == "release": 513 firefox_version -= 2 514 515 output_filename = f"runtime-metrics-{firefox_version}.json" 516 517 # Base URL for fetching YAML files from GitHub 518 GITHUB_URL_TEMPLATE = "https://raw.githubusercontent.com/mozilla-firefox/firefox/refs/heads/{branch}/browser/components/newtab/{yaml}" 519 520 main_metrics_yaml = yaml.safe_load(open(METRICS_LOCAL_YAML_PATH)) 521 compare_metrics_yaml = fetch_yaml( 522 GITHUB_URL_TEMPLATE.format(branch=channel, yaml="metrics.yaml") 523 ) 524 main_pings_yaml = yaml.safe_load(open(PINGS_LOCAL_YAML_PATH)) 525 compare_pings_yaml = fetch_yaml( 526 GITHUB_URL_TEMPLATE.format(branch=channel, yaml="pings.yaml") 527 ) 528 529 with tempfile.TemporaryDirectory() as temp_dir: 530 temp_dir_path = Path(temp_dir) 531 532 # 2. Process metrics and pings file 533 metrics_path = process_yaml_file( 534 main_metrics_yaml, compare_metrics_yaml, YamlType.METRICS, temp_dir_path 535 ) 536 pings_path = process_yaml_file( 537 main_pings_yaml, compare_pings_yaml, YamlType.PINGS, temp_dir_path 538 ) 539 540 # 3. Set up the input files 541 input_files = [metrics_path, pings_path] 542 543 # 4. Parse the YAML file 544 options = {"allow_reserved": False} 545 all_objs, options = parse_with_options(input_files, options) 546 547 # 5. Create output directory and file 548 WEBEXT_METRICS_PATH.mkdir(parents=True, exist_ok=True) 549 output_file_path = WEBEXT_METRICS_PATH / output_filename 550 551 # 6. Write to the output file 552 output_fd = io.StringIO() 553 glean_utils.output_file_with_key(all_objs, output_fd, options) 554 Path(output_file_path).write_text(output_fd.getvalue()) 555 556 # 7. Print warnings for any metrics with changed types or new extra_keys 557 changed_metrics = check_existing_metrics( 558 main_metrics_yaml, compare_metrics_yaml 559 ) 560 if changed_metrics: 561 print("\nWARNING: Found existing metrics with updated properties:") 562 for category, metrics in changed_metrics.items(): 563 print(f"\nCategory: {category}") 564 for metric, changes in metrics.items(): 565 print(f" Metric: {metric}") 566 if "type_change" in changes: 567 print( 568 f" Old type: {changes['type_change']['old_type']}" 569 ) 570 print( 571 f" New type: {changes['type_change']['new_type']}" 572 ) 573 if "new_extra_keys" in changes: 574 print( 575 f" New extra keys: {', '.join(changes['new_extra_keys'])}" 576 ) 577 print( 578 "\nPlease review above warning carefully as existing metrics update cannot be dynamically registered" 579 ) 580 581 except requests.RequestException as e: 582 print(f"Network error while fetching YAML files: {e}\nPlease try again.") 583 return 1 584 except yaml.YAMLError as e: 585 print(f"YAML parsing error: {e}\nPlease check that the YAML files are valid.") 586 return 1 587 except Exception as e: 588 print(f"An unexpected error occurred: {e}") 589 return 1 590 591 592 def fetch_yaml(url): 593 response = requests.get(url) 594 response.raise_for_status() 595 return yaml.safe_load(response.text) 596 597 598 def process_yaml_file(main_yaml, compare_yaml, yaml_type: YamlType, temp_dir_path): 599 """Helper function to process YAML content and write to temporary file. 600 601 Args: 602 main_yaml: The main branch YAML content 603 compare_yaml: The comparison branch YAML content 604 yaml_type: YamlType Enum value to determine which comparison function to use 605 temp_dir_path: Path object for the temporary directory 606 607 Returns: 608 Path object for the created temporary file 609 """ 610 if yaml_type == YamlType.METRICS: 611 new_yaml = get_new_metrics(main_yaml, compare_yaml) 612 filename = "newtab_metrics_only_new.yaml" 613 else: 614 new_yaml = get_new_pings(main_yaml, compare_yaml) 615 filename = "newtab_pings_only_new.yaml" 616 617 # Remove $tags if present to avoid invalid tag lint error 618 if "$tags" in new_yaml: 619 del new_yaml["$tags"] 620 new_yaml["no_lint"] = ["COMMON_PREFIX"] 621 622 yaml_content = yaml.dump(new_yaml, sort_keys=False) 623 print(yaml_content) 624 625 # Write to temporary file 626 file_path = temp_dir_path / filename 627 with open(file_path, "w") as f: 628 f.write(yaml_content) 629 630 return file_path 631 632 633 def get_new_metrics(main_yaml, compare_yaml): 634 """Compare main and comparison YAML files to find new metrics. 635 636 This function compares the metrics defined in the main branch against those in the comparison branch 637 (beta or release) and returns only the metrics that are new in the main branch. 638 639 Args: 640 main_yaml: The YAML content from the main branch containing metric definitions 641 compare_yaml: The YAML content from the comparison branch (beta/release) containing metric definitions 642 643 Returns: 644 dict: A dictionary containing only the metrics that are new in the main branch 645 """ 646 new_metrics_yaml = {} 647 for category in main_yaml: 648 if category.startswith("$"): 649 new_metrics_yaml[category] = main_yaml[category] 650 continue 651 if category not in compare_yaml: 652 new_metrics_yaml[category] = main_yaml[category] 653 continue 654 new_metrics = {} 655 for metric in main_yaml[category]: 656 if metric not in compare_yaml[category]: 657 new_metrics[metric] = main_yaml[category][metric] 658 if new_metrics: 659 new_metrics_yaml[category] = new_metrics 660 return new_metrics_yaml 661 662 663 def get_new_pings(main_yaml, compare_yaml): 664 """Compare main and comparison YAML files to find new pings. 665 666 This function compares the pings defined in the main branch against those in the comparison branch 667 (beta or release) and returns only the pings that are new in the main branch. 668 669 Args: 670 main_yaml: The YAML content from the main branch containing ping definitions 671 compare_yaml: The YAML content from the comparison branch (beta/release) containing ping definitions 672 673 Returns: 674 dict: A dictionary containing only the pings that are new in the main branch 675 """ 676 new_pings_yaml = {} 677 for ping in main_yaml: 678 if ping.startswith("$"): 679 new_pings_yaml[ping] = main_yaml[ping] 680 continue 681 if ping not in compare_yaml: 682 new_pings_yaml[ping] = main_yaml[ping] 683 continue 684 return new_pings_yaml 685 686 687 def check_existing_metrics(main_yaml, compare_yaml): 688 """Compare metrics that exist in both YAML files for: 689 1. Changes in type property values 690 2. New extra_keys added to event type metrics 691 692 Args: 693 main_yaml: The main YAML file containing metrics 694 compare_yaml: The comparison YAML file containing metrics 695 696 Returns: 697 A dictionary containing metrics with changes, organized by category. 698 Each entry contains either: 699 - type_change: old and new type values 700 - new_extra_keys: list of newly added extra keys for event metrics 701 """ 702 changed_metrics = {} 703 704 for category in main_yaml: 705 # Skip metadata categories that start with $ 706 if category.startswith("$"): 707 continue 708 709 # Skip categories that don't exist in compare_yaml 710 if category not in compare_yaml: 711 continue 712 713 category_changes = {} 714 for metric in main_yaml[category]: 715 # Only check metrics that exist in both YAMLs 716 if metric in compare_yaml[category]: 717 main_metric = main_yaml[category][metric] 718 compare_metric = compare_yaml[category][metric] 719 720 # Check for type changes 721 main_type = main_metric.get("type") 722 compare_type = compare_metric.get("type") 723 724 changes = {} 725 726 # If types are different, record the change 727 if main_type != compare_type: 728 changes["type_change"] = { 729 "old_type": compare_type, 730 "new_type": main_type, 731 } 732 733 # Check for changes in extra_keys for event metrics 734 if main_type == "event" and "extra_keys" in main_metric: 735 main_extra_keys = set(main_metric["extra_keys"].keys()) 736 compare_extra_keys = set( 737 compare_metric.get("extra_keys", {}).keys() 738 ) 739 740 # Find new extra keys 741 new_extra_keys = main_extra_keys - compare_extra_keys 742 if new_extra_keys: 743 changes["new_extra_keys"] = list(new_extra_keys) 744 745 # Only add the metric if there were any changes 746 if changes: 747 category_changes[metric] = changes 748 749 # Only add the category if there were changes 750 if category_changes: 751 changed_metrics[category] = category_changes 752 753 return changed_metrics 754 755 756 @SubCommand( 757 "newtab", 758 "trainhop-recipe", 759 description="""Generates the appropriate trainhop recipe for the Nimbus 760 newtabTrainhopAddon feature, given a Taskcluster shipping task group URL from 761 ship-it""", 762 ) 763 @CommandArgument( 764 "taskcluster_group_url", help="The shipping Taskcluster task group URL from ship-it" 765 ) 766 def trainhop_recipe(command_context, taskcluster_group_url): 767 tc_root_url = urlparse(TASKCLUSTER_ROOT_URL) 768 group_url = urlparse(taskcluster_group_url) 769 if group_url.scheme != "https" or group_url.hostname != tc_root_url.hostname: 770 print( 771 f"Expected an https URL with hostname {tc_root_url.hostname}. Got: {taskcluster_group_url}" 772 ) 773 return 1 774 775 group_id = group_url.path.split("/")[-1] 776 if not group_id: 777 print(f"Could not extract the task group ID from {taskcluster_group_url}") 778 return 1 779 780 print(f"Extracted task group ID {group_id}") 781 782 queue = taskcluster.Queue({"rootUrl": TASKCLUSTER_ROOT_URL}) 783 task_group = queue.listTaskGroup(group_id) 784 artifact_destination = "" 785 786 for task in task_group["tasks"]: 787 if task["task"]["metadata"]["name"] == BEETMOVER_TASK_NAME: 788 print(f"Found {BEETMOVER_TASK_NAME} task") 789 artifacts = task["task"]["payload"]["artifactMap"] 790 for artifact in artifacts: 791 if BEETMOVER_ARTIFACT_PATH in artifact["paths"]: 792 artifact_destination = artifact["paths"][BEETMOVER_ARTIFACT_PATH][ 793 "destinations" 794 ][0] 795 796 print(f"Got the destination: {artifact_destination}") 797 xpi_archive_url = urljoin(ARCHIVE_ROOT_PATH, artifact_destination) 798 print(f"Downloading from: {xpi_archive_url}") 799 800 with tempfile.TemporaryDirectory() as download_dir: 801 with requests.get(xpi_archive_url, stream=True) as request: 802 request.raise_for_status() 803 804 download_path = Path(download_dir).joinpath(XPI_NAME) 805 with open(download_path, "wb") as f: 806 for chunk in request.iter_content(chunk_size=8192): 807 # If you have chunk encoded response uncomment if 808 # and set chunk_size parameter to None. 809 # if chunk: 810 f.write(chunk) 811 812 # XPIs are just ZIP files, so let's reach in and read manifest.json. 813 with ZipFile(download_path) as newtab_xpi: 814 with newtab_xpi.open("manifest.json") as manifest_file: 815 manifest = json.loads(manifest_file.read()) 816 addon_version = manifest["version"] 817 818 shutil.rmtree(download_dir) 819 820 result = { 821 "addon_version": addon_version, 822 "xpi_download_path": "/".join(artifact_destination.split("/")[-2:]), 823 } 824 825 print("Nimbus train-hop recipe:\n\n") 826 print(json.dumps(result, indent=2, sort_keys=True)) 827 print("\n") 828 829 830 @SubCommand( 831 "newtab", 832 "bundle", 833 description="Bundle compiled newtab code.", 834 ) 835 def bundle(command_context): 836 """ 837 Runs: ./mach npm run bundle --prefix=browser/extensions/newtab 838 """ 839 proc = None 840 841 try: 842 proc = subprocess.Popen([ 843 "./mach", 844 "npm", 845 "run", 846 "bundle", 847 "--prefix=browser/extensions/newtab", 848 ]) 849 print("Bundling newtab started. Press Ctrl-C to terminate.") 850 proc.wait() 851 except KeyboardInterrupt: 852 if proc: 853 proc.terminate() 854 proc.wait() 855 print("Bundle process terminated.") 856 else: 857 # Successful bundle 858 if proc and proc.returncode == 0: 859 print("Bundling newtab completed.") 860 elif proc: 861 code = proc.returncode 862 print(f"Bundling newtab failed (exit code {code}). See logs above.") 863 864 if code == 127: 865 print( 866 "Hint: Required npm binaries not found. " 867 "Try running:\n ./mach newtab install" 868 ) 869 870 # Swallow errors (no exception raising). Return the process returncode for mach to surface. 871 return 0 if proc is None else proc.returncode 872 873 874 @SubCommand( 875 "newtab", 876 "install", 877 description="Installing node dependencies for newtab extension.", 878 ) 879 def install(command_context): 880 """ 881 Runs: ./mach npm install --prefix=browser/extensions/newtab 882 """ 883 proc = None 884 885 try: 886 proc = subprocess.Popen([ 887 "./mach", 888 "npm", 889 "install", 890 "--prefix=browser/extensions/newtab", 891 ]) 892 print( 893 "Installing node dependencies for newtab started. Press Ctrl-C to terminate." 894 ) 895 proc.wait() 896 except KeyboardInterrupt: 897 if proc: 898 proc.terminate() 899 proc.wait() 900 print("Install process terminated.") 901 else: 902 print("Installing node dependencies for newtab completed.") 903 904 # Swallow errors (no exception raising). Return the process returncode for mach to surface. 905 return 0 if proc is None else proc.returncode 906 907 908 @SubCommand( 909 "newtab", 910 "get-unbranded-builds", 911 description="Get URLs for the latest unbranded Firefox builds for add-on development.", 912 ) 913 @CommandArgument( 914 "--channel", 915 default="release", 916 choices=["beta", "release"], 917 help="Which channel to get unbranded builds for", 918 ) 919 def get_unbranded_builds(command_context, channel): 920 """ 921 Prints the latest unbranded Firefox build artifact URLs for Mac, Windows, and Linux. 922 These builds allow testing unsigned extensions without enforcing signature requirements. 923 """ 924 TASKCLUSTER_INDEX_URL = ( 925 "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task" 926 ) 927 928 repo = f"mozilla-{channel}" 929 930 platforms = { 931 "macOS (Intel)": { 932 "namespace": f"gecko.v2.{repo}.latest.firefox.macosx64-add-on-devel", 933 "artifact": "public/build/target.dmg", 934 }, 935 "Windows 32-bit": { 936 "namespace": f"gecko.v2.{repo}.latest.firefox.win32-add-on-devel", 937 "artifact": "public/build/target.zip", 938 }, 939 "Windows 64-bit": { 940 "namespace": f"gecko.v2.{repo}.latest.firefox.win64-add-on-devel", 941 "artifact": "public/build/target.zip", 942 }, 943 "Linux 64-bit": { 944 "namespace": f"gecko.v2.{repo}.latest.firefox.linux64-add-on-devel", 945 "artifact": "public/build/target.tar.bz2", 946 }, 947 } 948 949 print(f"Fetching latest unbranded builds for {channel} channel...\n") 950 951 for platform_name, platform_info in platforms.items(): 952 namespace = platform_info["namespace"] 953 artifact = platform_info["artifact"] 954 955 try: 956 index_url = f"{TASKCLUSTER_INDEX_URL}/{namespace}" 957 response = requests.get(index_url, timeout=10) 958 response.raise_for_status() 959 task_data = response.json() 960 task_id = task_data["taskId"] 961 962 artifact_url = f"https://firefox-ci-tc.services.mozilla.com/api/queue/v1/task/{task_id}/runs/0/artifacts/{artifact}" 963 964 print(f"{platform_name}:") 965 print(f" {artifact_url}\n") 966 except requests.RequestException as e: 967 print(f"{platform_name}: Failed to fetch ({e})\n") 968 969 print( 970 "For more information, see: https://wiki.mozilla.org/Add-ons/Extension_Signing#Unbranded_Builds" 971 ) 972 print( 973 f"Manual search: https://treeherder.mozilla.org/#/jobs?repo={repo}&searchStr=addon" 974 )