tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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    )