diff --git a/api/src/shipit_api/admin/api.yml b/api/src/shipit_api/admin/api.yml index a840f3952..4cb124663 100644 --- a/api/src/shipit_api/admin/api.yml +++ b/api/src/shipit_api/admin/api.yml @@ -43,6 +43,142 @@ paths: state: type: string enum: [ready, missing] + /merge-automation: + get: + summary: Get the list of merge automations for the given product + operationId: shipit_api.admin.merge_automation.list_merge_automation + parameters: + - name: product + in: query + description: product_name + required: true + schema: + $ref: '#/components/schemas/ProductInput' + responses: + "200": + description: The list of merge automations triggered for this product, limited to the 20 more recent + content: + application/json: + schema: + type: object + post: + summary: Submit a new merge automation for the given product + operationId: shipit_api.admin.merge_automation.submit_merge_automation + requestBody: + description: Merge automation object + content: + application/json: + schema: + $ref: '#/components/schemas/MergeAutomationInput' + required: true + responses: + "201": + description: The merge automation has been successfully submitted + "405": + description: Invalid input + /merge-automation/{automation_id}: + delete: + summary: Cancel a merge automation + operationId: shipit_api.admin.merge_automation.cancel_merge_automation + parameters: + - name: automation_id + in: path + description: automation id + required: true + schema: + type: integer + responses: + "200": + description: Merge automation cancelled successfully + "404": + description: Merge automation not found + patch: + summary: Mark merge automation as completed + operationId: shipit_api.admin.merge_automation.mark_merge_automation_completed + parameters: + - name: automation_id + in: path + description: automation id + required: true + schema: + type: integer + responses: + "200": + description: Merge automation marked as completed + "400": + description: Merge automation cannot be marked as completed + "404": + description: Merge automation not found + /merge-automation/{automation_id}/start: + post: + summary: Start a pending merge automation + operationId: shipit_api.admin.merge_automation.start_merge_automation + parameters: + - name: automation_id + in: path + description: automation id + required: true + schema: + type: integer + responses: + "200": + description: Merge automation started successfully + "404": + description: Merge automation not found + "400": + description: Merge automation cannot be started + /merge-automation/{automation_id}/task-status: + get: + summary: Get taskcluster task status for a merge automation + operationId: shipit_api.admin.merge_automation.get_merge_automation_task_status + parameters: + - name: automation_id + in: path + description: automation id + required: true + schema: + type: integer + responses: + "200": + description: Task status retrieved successfully + content: + application/json: + schema: + type: object + properties: + task_id: + type: string + state: + type: string + enum: [ready, missing] + automation: + type: object + decisionTask: + type: object + taskGroup: + type: object + "404": + description: Merge automation not found + /merge-automation/behaviors/{product}: + get: + summary: Get the list of available merge behaviors for the given product + operationId: shipit_api.admin.merge_automation.list_behaviors + parameters: + - name: product + in: path + description: product name + required: true + schema: + $ref: '#/components/schemas/ProductInput' + responses: + "200": + description: The list of available merge behaviors + content: + application/json: + schema: + type: object + "404": + description: No merge behaviors found for the given product /product-details: post: summary: Trigger a rebuild of product details @@ -839,6 +975,24 @@ paths: components: schemas: + MergeAutomationInput: + required: + - product + - behavior + - revision + - dryRun + type: object + properties: + product: + $ref: '#/components/schemas/ProductInput' + behavior: + type: string + example: beta-to-release + revision: + type: string + example: 706aadff79254a41b5b5d9616b51d8365cbae573 + dryRun: + type: boolean ReleaseInput: required: - branch diff --git a/api/src/shipit_api/admin/auth0.py b/api/src/shipit_api/admin/auth0.py index aa7eb8828..0f50bfde0 100644 --- a/api/src/shipit_api/admin/auth0.py +++ b/api/src/shipit_api/admin/auth0.py @@ -21,6 +21,8 @@ def _get_auth0_scopes(product_name, product_config): scopes = [ f"add_release/{product_name}", f"abandon_release/{product_name}", + f"add_merge_automation/{product_name}", + f"cancel_merge_automation/{product_name}", ] phases = [f["name"] for f in SUPPORTED_FLAVORS.get(product_name, [])] if product_name == "firefox": diff --git a/api/src/shipit_api/admin/merge_automation.py b/api/src/shipit_api/admin/merge_automation.py new file mode 100644 index 000000000..16ceca6b3 --- /dev/null +++ b/api/src/shipit_api/admin/merge_automation.py @@ -0,0 +1,267 @@ +import datetime +import logging + +from flask import abort, request +from flask_login import current_user +from taskcluster.exceptions import TaskclusterRestFailure + +from backend_common.db import db +from backend_common.taskcluster import get_service +from shipit_api.admin.tasks import ( + cancel_action_task_group, + fetch_group_tasks, + find_action, + find_decision_task_id, + get_actions, + get_parameters, + render_action_hook, +) +from shipit_api.common.config import MERGE_BEHAVIORS_PER_PRODUCT, SCOPE_PREFIX +from shipit_api.common.models import MergeAutomation, TaskStatus + +logger = logging.getLogger(__name__) + + +def get_behavior_for_product(product, behavior_name): + if product not in MERGE_BEHAVIORS_PER_PRODUCT: + abort(404, f"No merge behavior found for product: {product}") + + behavior = MERGE_BEHAVIORS_PER_PRODUCT[product].get(behavior_name) + if not behavior: + abort(404, f"Behavior {behavior_name} not found for product: {product}") + + return behavior + + +def list_behaviors(product): + if product not in MERGE_BEHAVIORS_PER_PRODUCT: + abort(404, f"No merge behavior found for product: {product}") + + return MERGE_BEHAVIORS_PER_PRODUCT[product] + + +def submit_merge_automation(): + body = request.get_json() + product = body["product"] + + required_permission = f"{SCOPE_PREFIX}/add_merge_automation/{product}" + if not current_user.has_permissions(required_permission): + user_permissions = ", ".join(current_user.get_permissions()) + abort(401, f"required permission: {required_permission}, user permissions: {user_permissions}") + + behavior_name = body["behavior"] + revision = body["revision"] + dry_run = body.get("dryRun", True) + version = body["version"] + commit_message = body["commitMessage"] + commit_author = body["commitAuthor"] + + behavior = get_behavior_for_product(product, behavior_name) + + automation = MergeAutomation( + product=product, + behavior=behavior_name, + revision=revision, + version=version, + status=TaskStatus.Pending, + dry_run=dry_run, + commit_message=commit_message, + commit_author=commit_author, + repo=behavior["repo"], + pretty_name=behavior["pretty_name"], + project=behavior["project"], + ) + + db.session.add(automation) + db.session.commit() + + return {"message": "Merge automation created successfully"}, 201 + + +def list_merge_automation(product): + if product not in MERGE_BEHAVIORS_PER_PRODUCT: + return abort(404, f"No merge behavior found for product: {product}") + + merge_automations = ( + MergeAutomation.query.filter_by(product=product) + .filter(MergeAutomation.status != TaskStatus.Canceled) + .order_by(db.case((MergeAutomation.status == TaskStatus.Completed, 1), else_=0), MergeAutomation.created.desc()) + .limit(20) + .all() + ) + + return [automation.json for automation in merge_automations] + + +def cancel_merge_automation(automation_id): + automation = db.session.get(MergeAutomation, automation_id) + if not automation: + return abort(404, f"Merge automation with id {automation_id} not found") + + required_permission = f"{SCOPE_PREFIX}/cancel_merge_automation/{automation.product}" + if not current_user.has_permissions(required_permission): + user_permissions = ", ".join(current_user.get_permissions()) + abort(401, f"required permission: {required_permission}, user permissions: {user_permissions}") + + logger.info(f"Cancelling merge automation with id {automation_id}") + if automation.task_id: + try: + cancel_action_task_group(automation.task_id) + except TaskclusterRestFailure as e: + abort(400, str(e)) + + automation.status = TaskStatus.Canceled + db.session.commit() + + return automation.json + + +def mark_merge_automation_completed(automation_id): + automation = db.session.get(MergeAutomation, automation_id) + if not automation: + return abort(404, f"Merge automation with id {automation_id} not found") + + required_permission = f"{SCOPE_PREFIX}/mark_merge_automation_completed/{automation.product}" + if not current_user.has_permissions(required_permission): + user_permissions = ", ".join(current_user.get_permissions()) + abort(401, f"required permission: {required_permission}, user permissions: {user_permissions}") + + if automation.status in (TaskStatus.Completed, TaskStatus.Canceled): + return abort(400, f"Cannot update automation in {automation.status.name} status") + + automation.status = TaskStatus.Completed + automation.completed = datetime.datetime.now(datetime.UTC) + + db.session.commit() + logger.info(f"Merge automation {automation_id} marked as completed") + + return automation.json + + +def start_merge_automation(automation_id): + automation = db.session.get(MergeAutomation, automation_id) + if not automation: + return abort(404, f"Merge automation with id {automation_id} not found") + + # Check permissions + required_permission = f"{SCOPE_PREFIX}/add_merge_automation/{automation.product}" + if not current_user.has_permissions(required_permission): + user_permissions = ", ".join(current_user.get_permissions()) + abort(401, f"required permission: {required_permission}, user permissions: {user_permissions}") + + if automation.status != TaskStatus.Pending: + return abort(400, f"Cannot start automation in {automation.status.name} status") + + try: + task_id = trigger_merge_automation_action(automation) + automation.task_id = task_id + automation.status = TaskStatus.Running + db.session.commit() + + logger.info(f"Started merge automation {automation.id} with task {task_id}") + return {"message": "Merge automation started successfully", "task_id": task_id} + except Exception as e: + db.session.rollback() + logger.error(f"Failed to start merge automation {automation.id}: {e}") + return abort(500, f"Failed to start merge automation: {str(e)}") + + +def trigger_merge_automation_action(automation): + decision_task_id = find_decision_task_id(automation.repo, automation.project, automation.revision, automation.product) + actions = get_actions(decision_task_id) + + merge_action = find_action("merge-automation", actions) + if not merge_action: + raise ValueError("merge-automation action not found in decision task") + + action_input = { + "behavior": automation.behavior, + "force-dry-run": automation.dry_run, + "merge-automation-id": automation.id, + } + + hooks = get_service("hooks") + client_id = hooks.options["credentials"]["clientId"].decode("utf-8") + + parameters = get_parameters(decision_task_id) + + context = { + "parameters": parameters, + "taskGroupId": decision_task_id, + "taskId": None, + "task": None, + "input": action_input, + "clientId": client_id, + } + + hook_payload_rendered = render_action_hook(payload=merge_action["hookPayload"], context=context) + + try: + result = hooks.triggerHook( + merge_action["hookGroupId"], + merge_action["hookId"], + hook_payload_rendered, + ) + except TaskclusterRestFailure as e: + abort(400, str(e)) + + task_id = result["status"]["taskId"] + logger.info(f"Triggered merge automation action, task ID: {task_id}") + return task_id + + +def get_task_status(task_id): + queue = get_service("queue") + task_status = queue.status(task_id) + return { + "taskId": task_id, + "state": task_status["status"]["state"], + } + + +def get_task_group_status(task_group_id): + try: + tasks = fetch_group_tasks(task_group_id) + except TaskclusterRestFailure as e: + if e.status_code == 404: + return TaskStatus.Pending + raise + + group_status = TaskStatus.Completed + for task in tasks: + state = task["status"]["state"] + + if state in ("failed", "exception"): + group_status = TaskStatus.Failed + break + + if state in ("running", "pending", "unscheduled"): + group_status = TaskStatus.Running + + return group_status + + +def get_merge_automation_task_status(automation_id): + automation = db.session.get(MergeAutomation, automation_id) + if not automation or not automation.task_id: + return abort(404, "Automation not found or no task ID") + + if automation.status in (TaskStatus.Completed, TaskStatus.Canceled): + return { + "automation": automation.json, + "decisionTask": {"taskId": automation.task_id, "state": automation.status.name.lower()}, + "taskGroup": {"overallStatus": automation.status.name.lower()}, + } + + decision_task_status = get_task_status(automation.task_id) + + if decision_task_status["state"] == "completed": + overall_status = get_task_group_status(automation.task_id) + else: + overall_status = TaskStatus.Pending + + return { + "automation": automation.json, + "decisionTask": decision_task_status, + "taskGroup": {"overallStatus": overall_status.name.lower()}, + } diff --git a/api/src/shipit_api/admin/tasks.py b/api/src/shipit_api/admin/tasks.py index efb6656f8..c4b40c3b9 100644 --- a/api/src/shipit_api/admin/tasks.py +++ b/api/src/shipit_api/admin/tasks.py @@ -62,10 +62,17 @@ def find_decision_task_id(repo_url, project, revision, product): def fetch_group_tasks(task_id): queue = get_service("queue") + tasks = [] + + def collect_tasks(response): + tasks.extend(response["tasks"]) + try: - return queue.listTaskGroup(task_id)["tasks"] + queue.listTaskGroup(task_id, paginationHandler=collect_tasks) except Exception as exc: - raise Exception(f"task {task_id} exception {exc}") + logging.exception(f"task {task_id} exception {exc}") + raise + return tasks def fetch_latest_artifacts(task_id): diff --git a/api/src/shipit_api/common/config.py b/api/src/shipit_api/common/config.py index 8cabac596..eeb72ccef 100644 --- a/api/src/shipit_api/common/config.py +++ b/api/src/shipit_api/common/config.py @@ -3,6 +3,7 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. +import os import pathlib import re import tempfile @@ -559,3 +560,81 @@ def get_allowed_github_files(owner: str, repo: str) -> set[re.Pattern]: allowed_paths.add(r"one/package.json") return {re.compile(s) for s in allowed_paths} + + +# NOTE: This duplicates some configuration between the backend and the frontend (mainly the repo names). +# However, the frontend should never have half of the config it has in the first place and it should get moved at some point. +# See bug 1879910 +_MERGE_BEHAVIORS_PER_PRODUCT = { + "firefox": { + "main-to-beta": { + "pretty_name": "Main -> beta", + "by-env": { + "local": { + "always-target-tip": False, + "repo": "https://hg.mozilla.org/try", + "project": "try", + "version_path": "browser/config/version_display.txt", + }, + "staging": { + "always-target-tip": False, + "repo": "https://hg.mozilla.org/try", + "project": "try", + "version_path": "browser/config/version_display.txt", + }, + "production": { + "always-target-tip": True, + "repo": "https://hg.mozilla.org/mozilla-central", + "project": "mozilla-central", + "version_path": "browser/config/version_display.txt", + }, + }, + }, + "beta-to-release": { + "pretty_name": "Beta -> release", + "by-env": { + "local": { + "always-target-tip": True, + "repo": "https://hg.mozilla.org/releases/mozilla-beta", + "project": "mozilla-beta", + "version_path": "browser/config/version_display.txt", + }, + "staging": { + "always-target-tip": False, + "repo": "https://hg.mozilla.org/try", + "project": "try", + "version_path": "browser/config/version_display.txt", + }, + "production": { + "always-target-tip": True, + "repo": "https://hg.mozilla.org/releases/mozilla-beta", + "project": "mozilla-beta", + "version_path": "browser/config/version_display.txt", + }, + }, + }, + } +} + + +def resolve_config_by_environment(config, environment): + def _resolve(obj): + if isinstance(obj, dict) and "by-env" in obj: + env_mapping = obj["by-env"] + env_value = env_mapping[environment] + + result = {k: _resolve(v) for k, v in obj.items() if k != "by-env"} + if isinstance(env_value, dict): + result.update(_resolve(env_value)) + else: + return _resolve(env_value) + return result + + if isinstance(obj, dict): + return {k: _resolve(v) for k, v in obj.items()} + return obj + + return _resolve(config) + + +MERGE_BEHAVIORS_PER_PRODUCT = resolve_config_by_environment(_MERGE_BEHAVIORS_PER_PRODUCT, os.environ.get("APP_CHANNEL", "local")) diff --git a/api/src/shipit_api/common/models.py b/api/src/shipit_api/common/models.py index 944643046..ed6e1898d 100644 --- a/api/src/shipit_api/common/models.py +++ b/api/src/shipit_api/common/models.py @@ -5,6 +5,7 @@ import dataclasses import datetime +import enum import json import slugid @@ -176,6 +177,59 @@ class DisabledProduct(db.Model): branch = sa.Column(sa.String, nullable=False, primary_key=True) +class TaskStatus(enum.Enum): + Pending = enum.auto() + Running = enum.auto() + Failed = enum.auto() + Completed = enum.auto() + Canceled = enum.auto() + + +class MergeAutomation(db.Model): + __tablename__ = "shipit_api_merge_automation" + id = sa.Column(sa.Integer, primary_key=True) + + # User controlled properties + product = sa.Column(sa.String, nullable=False) + behavior = sa.Column(sa.String, nullable=False) + revision = sa.Column(sa.String, nullable=False) + dry_run = sa.Column(sa.Boolean, default=True, nullable=False) + + created = sa.Column(sa.DateTime, default=lambda: datetime.datetime.now(datetime.UTC), nullable=False) + completed = sa.Column(sa.DateTime) + status = sa.Column(sa.types.Enum(TaskStatus), nullable=False, default=TaskStatus.Pending) + task_id = sa.Column(sa.String) + + # "Cached" values to avoid querying hg again + version = sa.Column(sa.String, nullable=False) + commit_message = sa.Column(sa.Text) + commit_author = sa.Column(sa.String) + + # Stored config values to avoid depending on configuration values that might be gone + repo = sa.Column(sa.String, nullable=False) + pretty_name = sa.Column(sa.String, nullable=False) + project = sa.Column(sa.String, nullable=False) + + @property + def json(self): + return { + "id": self.id, + "product": self.product, + "behavior": self.behavior, + "revision": self.revision, + "dry_run": self.dry_run, + "created": self.created.isoformat(), + "completed": self.completed.isoformat() if self.completed else None, + "status": self.status.name.lower(), + "task_id": self.task_id, + "version": self.version, + "commit_message": self.commit_message, + "commit_author": self.commit_author, + "repo": self.repo, + "pretty_name": self.pretty_name, + } + + @dataclasses.dataclass class XPI: name: str diff --git a/api/tests/test_auth0.py b/api/tests/test_auth0.py index e04a9d401..03d277ef6 100644 --- a/api/tests/test_auth0.py +++ b/api/tests/test_auth0.py @@ -12,6 +12,8 @@ [ "add_release/can-be-disabled-product", "abandon_release/can-be-disabled-product", + "add_merge_automation/can-be-disabled-product", + "cancel_merge_automation/can-be-disabled-product", "disable_product/can-be-disabled-product", "enable_product/can-be-disabled-product", ], @@ -22,6 +24,8 @@ [ "add_release/product-on-github", "abandon_release/product-on-github", + "add_merge_automation/product-on-github", + "cancel_merge_automation/product-on-github", "github", ], ), @@ -45,6 +49,8 @@ [ "add_release/firefox", "abandon_release/firefox", + "add_merge_automation/firefox", + "cancel_merge_automation/firefox", "schedule_phase/firefox/promote_firefox", "phase_signoff/firefox/promote_firefox", "schedule_phase/firefox/push_firefox", diff --git a/api/tests/test_merge_automation.py b/api/tests/test_merge_automation.py new file mode 100644 index 000000000..5f7b106d0 --- /dev/null +++ b/api/tests/test_merge_automation.py @@ -0,0 +1,743 @@ +from unittest.mock import Mock, patch + +import pytest + +from backend_common.db import db +from shipit_api.admin.merge_automation import get_task_group_status, get_task_status, trigger_merge_automation_action +from shipit_api.common.models import MergeAutomation, TaskStatus + + +def create_merge_automation_with_defaults(**kwargs): + defaults = { + "repo": "https://hg.mozilla.org/try", + "pretty_name": "Main -> beta", + "project": "try", + } + defaults.update(kwargs) + return MergeAutomation(**defaults) + + +@pytest.mark.parametrize( + "product,expected_error", + ( + pytest.param("firefox", False), + pytest.param("thunderbird", True), + ), +) +def test_list_behaviors(app, product, expected_error): + with app.test_client() as client: + response = client.get(f"/merge-automation/behaviors/{product}") + if expected_error: + assert response.status_code == 404 + return + + assert response.status_code == 200 + + def assert_key_is(key, ty, obj): + assert key in obj and isinstance(obj[key], ty) + + for name, behavior in response.json().items(): + assert_key_is("repo", str, behavior) + assert_key_is("pretty_name", str, behavior) + assert_key_is("always-target-tip", bool, behavior) + + +def test_list_merge_automation_no_data(app): + with app.test_client() as client: + response = client.get("/merge-automation?product=firefox") + assert response.status_code == 200 + assert response.json() == [] + + with app.test_client() as client: + response = client.get("/merge-automation?product=thunderbird") + assert response.status_code == 404 + assert response.json()["detail"] == "No merge behavior found for product: thunderbird" + + +def test_list_merge_automation_with_data(app): + automation1 = create_merge_automation_with_defaults( + product="firefox", behavior="main-to-beta", revision="abc123", version="130.0", status=TaskStatus.Pending, dry_run=True + ) + automation2 = create_merge_automation_with_defaults( + product="firefox", behavior="main-to-beta", revision="ghi789", version="128.0", status=TaskStatus.Completed, dry_run=False + ) + automation3 = create_merge_automation_with_defaults( + product="firefox", + behavior="beta-to-release", + revision="def456", + version="129.0", + status=TaskStatus.Running, + dry_run=False, + repo="https://hg.mozilla.org/releases/mozilla-beta", + pretty_name="Beta -> release", + project="mozilla-beta", + ) + + db.session.add(automation1) + db.session.add(automation2) + db.session.add(automation3) + db.session.commit() + + with app.test_client() as client: + response = client.get("/merge-automation?product=firefox") + assert response.status_code == 200 + + expected = [ + { + "id": automation3.id, + "product": "firefox", + "behavior": "beta-to-release", + "pretty_name": "Beta -> release", + "revision": "def456", + "version": "129.0", + "repo": "https://hg.mozilla.org/releases/mozilla-beta", + "status": "running", + "created": automation3.created.isoformat(), + "completed": None, + "task_id": None, + "dry_run": False, + "commit_message": None, + "commit_author": None, + }, + { + "id": automation1.id, + "product": "firefox", + "behavior": "main-to-beta", + "pretty_name": "Main -> beta", + "revision": "abc123", + "version": "130.0", + "repo": "https://hg.mozilla.org/try", + "status": "pending", + "created": automation1.created.isoformat(), + "completed": None, + "task_id": None, + "dry_run": True, + "commit_message": None, + "commit_author": None, + }, + { + "id": automation2.id, + "product": "firefox", + "behavior": "main-to-beta", + "pretty_name": "Main -> beta", + "revision": "ghi789", + "version": "128.0", + "repo": "https://hg.mozilla.org/try", + "status": "completed", + "created": automation2.created.isoformat(), + "completed": None, + "task_id": None, + "dry_run": False, + "commit_message": None, + "commit_author": None, + }, + ] + + assert response.json() == expected + + +@patch("shipit_api.admin.merge_automation.current_user") +def test_submit_merge_automation_success(mock_user, app): + mock_user.has_permissions.return_value = True + + with app.test_client() as client: + payload = { + "product": "firefox", + "behavior": "main-to-beta", + "revision": "abc123", + "dryRun": True, + "version": "130.0", + "commitMessage": "Test commit message", + "commitAuthor": "test@example.com", + } + + response = client.post("/merge-automation", json=payload) + assert response.status_code == 201 + assert response.json() == {"message": "Merge automation created successfully"} + + mock_user.has_permissions.assert_called_once_with("project:releng:services/shipit_api/add_merge_automation/firefox") + + +@pytest.mark.parametrize( + "product,behavior,expected_error_message", + [ + ("thunderbird", "main-to-beta", None), + ("firefox", "invalid-behavior", "Behavior invalid-behavior not found for product: firefox"), + ], +) +@patch("shipit_api.admin.merge_automation.current_user") +def test_submit_merge_automation_invalid_inputs(mock_user, app, product, behavior, expected_error_message): + mock_user.has_permissions.return_value = True + + with app.test_client() as client: + payload = { + "product": product, + "behavior": behavior, + "revision": "abc123", + "dryRun": True, + "version": "130.0", + "commitMessage": "Test commit", + "commitAuthor": "test@example.com", + } + + response = client.post("/merge-automation", json=payload) + assert response.status_code == 404 + + if expected_error_message: + assert response.json()["detail"] == expected_error_message + + +@patch("shipit_api.admin.merge_automation.current_user") +@patch("shipit_api.admin.merge_automation.cancel_action_task_group") +def test_cancel_merge_automation_success(mock_cancel, mock_user, app): + mock_user.has_permissions.return_value = True + automation = create_merge_automation_with_defaults( + product="firefox", behavior="main-to-beta", revision="abc123", version="130.0", status=TaskStatus.Pending, dry_run=True + ) + db.session.add(automation) + db.session.commit() + automation_id = automation.id + + with app.test_client() as client: + response = client.delete(f"/merge-automation/{automation_id}") + assert response.status_code == 200 + assert response.json()["status"] == "canceled" + + canceled_automation = db.session.get(MergeAutomation, automation_id) + assert canceled_automation is not None + assert canceled_automation.status == TaskStatus.Canceled + + +@patch("shipit_api.admin.merge_automation.current_user") +@patch("shipit_api.admin.merge_automation.cancel_action_task_group") +def test_cancel_merge_automation_with_taskcluster_task(mock_cancel, mock_user, app): + mock_user.has_permissions.return_value = True + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=TaskStatus.Running, + task_id="fakeTaskId", + dry_run=False, + ) + db.session.add(automation) + db.session.commit() + automation_id = automation.id + + with app.test_client() as client: + response = client.delete(f"/merge-automation/{automation_id}") + assert response.status_code == 200 + assert response.json()["status"] == "canceled" + + canceled_automation = db.session.get(MergeAutomation, automation_id) + assert canceled_automation is not None + assert canceled_automation.status == TaskStatus.Canceled + + mock_cancel.assert_called_once_with("fakeTaskId") + + +def test_cancel_merge_automation_not_found(app): + with app.test_client() as client: + response = client.delete("/merge-automation/999") + assert response.status_code == 404 + assert response.json()["detail"] == "Merge automation with id 999 not found" + + +@patch("shipit_api.admin.merge_automation.current_user") +@patch("shipit_api.admin.merge_automation.cancel_action_task_group") +def test_cancel_merge_automation_failed_status(mock_cancel, mock_user, app): + mock_user.has_permissions.return_value = True + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=TaskStatus.Failed, + task_id="fakeTaskId", + dry_run=False, + ) + db.session.add(automation) + db.session.commit() + automation_id = automation.id + + with app.test_client() as client: + response = client.delete(f"/merge-automation/{automation_id}") + assert response.status_code == 200 + assert response.json()["status"] == "canceled" + + canceled_automation = db.session.get(MergeAutomation, automation_id) + assert canceled_automation is not None + assert canceled_automation.status == TaskStatus.Canceled + + +@patch("shipit_api.admin.merge_automation.current_user") +@patch("shipit_api.admin.merge_automation.trigger_merge_automation_action") +def test_start_merge_automation_success(mock_trigger, mock_user, app): + mock_user.has_permissions.return_value = True + mock_trigger.return_value = "fakeTaskId" + + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=TaskStatus.Pending, + dry_run=True, + commit_message="Test commit", + commit_author="test@example.com", + ) + db.session.add(automation) + db.session.commit() + + with app.test_client() as client: + response = client.post(f"/merge-automation/{automation.id}/start") + assert response.status_code == 200 + assert response.json() == {"message": "Merge automation started successfully", "task_id": "fakeTaskId"} + + updated_automation = db.session.get(MergeAutomation, automation.id) + assert updated_automation.status == TaskStatus.Running + assert updated_automation.task_id == "fakeTaskId" + + mock_trigger.assert_called_once_with(automation) + + +@pytest.mark.parametrize( + "automation_id,automation_status,expected_status_code,expected_error", + [ + (999, None, 404, "Merge automation with id 999 not found"), + (None, TaskStatus.Running, 400, "Cannot start automation in Running status"), + ], +) +@patch("shipit_api.admin.merge_automation.current_user") +def test_start_merge_automation_error_cases(mock_user, app, automation_id, automation_status, expected_status_code, expected_error): + mock_user.has_permissions.return_value = True + + if automation_status is not None: + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=automation_status, + dry_run=True, + commit_message="Test commit", + commit_author="test@example.com", + ) + db.session.add(automation) + db.session.commit() + automation_id = automation.id + + with app.test_client() as client: + response = client.post(f"/merge-automation/{automation_id}/start") + assert response.status_code == expected_status_code + assert response.json()["detail"] == expected_error + + +@patch("shipit_api.admin.merge_automation.current_user") +@patch("shipit_api.admin.merge_automation.trigger_merge_automation_action") +def test_start_merge_automation_taskcluster_failure(mock_trigger, mock_user, app): + mock_user.has_permissions.return_value = True + mock_trigger.side_effect = Exception("taskcluster error") + + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=TaskStatus.Pending, + dry_run=True, + commit_message="Test commit", + commit_author="test@example.com", + ) + db.session.add(automation) + db.session.commit() + + with app.test_client() as client: + response = client.post(f"/merge-automation/{automation.id}/start") + assert response.status_code == 500 + assert "Failed to start merge automation: taskcluster error" in response.json()["detail"] + + updated_automation = db.session.get(MergeAutomation, automation.id) + assert updated_automation.status == TaskStatus.Pending + assert updated_automation.task_id is None + + +@patch("shipit_api.admin.merge_automation.get_service") +@patch("shipit_api.admin.merge_automation.find_decision_task_id") +@patch("shipit_api.admin.merge_automation.get_actions") +@patch("shipit_api.admin.merge_automation.get_parameters") +@patch("shipit_api.admin.merge_automation.render_action_hook") +def test_trigger_merge_automation_action_hook_payload(mock_render_hook, mock_get_parameters, mock_get_actions, mock_find_decision, mock_get_service, app): + mock_decision_task_id = "fakeTaskId" + mock_find_decision.return_value = mock_decision_task_id + + mock_actions = { + "actions": [ + {"name": "wrongaction", "hookGroupId": "test-group", "hookId": "test-hook", "hookPayload": {"some": "payload"}}, + {"name": "merge-automation", "hookGroupId": "test-group", "hookId": "test-hook", "hookPayload": {"some": "payload"}}, + ] + } + mock_get_actions.return_value = mock_actions + + mock_parameters = {"parameter1": "value1"} + mock_get_parameters.return_value = mock_parameters + + mock_rendered_payload = {"rendered": "payload"} + mock_render_hook.return_value = mock_rendered_payload + + mock_hooks_service = Mock() + mock_hooks_service.options = {"credentials": {"clientId": b"test-client"}} + mock_hooks_service.triggerHook.return_value = {"status": {"taskId": "fakeResultTaskId"}} + mock_get_service.return_value = mock_hooks_service + + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=TaskStatus.Pending, + dry_run=True, + ) + + result = trigger_merge_automation_action(automation) + mock_find_decision.assert_called_once_with("https://hg.mozilla.org/try", "try", "abc123", "firefox") + + mock_get_actions.assert_called_once_with(mock_decision_task_id) + mock_get_parameters.assert_called_once_with(mock_decision_task_id) + + expected_context = { + "parameters": mock_parameters, + "taskGroupId": mock_decision_task_id, + "taskId": None, + "task": None, + "input": { + "behavior": "main-to-beta", + "force-dry-run": True, + "merge-automation-id": automation.id, + }, + "clientId": "test-client", + } + mock_render_hook.assert_called_once_with(payload={"some": "payload"}, context=expected_context) + + mock_hooks_service.triggerHook.assert_called_once_with("test-group", "test-hook", mock_rendered_payload) + + assert result == "fakeResultTaskId" + + +@patch("shipit_api.admin.merge_automation.get_service") +@patch("shipit_api.admin.merge_automation.find_decision_task_id") +@patch("shipit_api.admin.merge_automation.get_actions") +def test_trigger_merge_automation_action_missing_action(mock_get_actions, mock_find_decision, mock_get_service, app): + mock_find_decision.return_value = "decision-task-123" + mock_get_actions.return_value = {"actions": [{"name": "other-action"}]} + + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=TaskStatus.Pending, + dry_run=True, + ) + + with pytest.raises(ValueError, match="merge-automation action not found in decision task"): + trigger_merge_automation_action(automation) + + +@pytest.mark.parametrize( + "initial_status,task_states,expected_task_group_status", + [ + (TaskStatus.Running, ["completed", "completed"], TaskStatus.Completed), + (TaskStatus.Running, ["completed", "failed"], TaskStatus.Failed), + (TaskStatus.Running, ["running", "completed"], TaskStatus.Running), + ], +) +@patch("shipit_api.admin.merge_automation.get_service") +@patch("shipit_api.admin.tasks.get_service") +def test_get_merge_automation_task_status_scenarios(mock_tasks_get_service, mock_get_service, app, initial_status, task_states, expected_task_group_status): + task_group_response = {"tasks": [{"status": {"state": state}} for state in task_states]} + + def mock_list_task_group(task_id, paginationHandler): + paginationHandler(task_group_response) + + mock_queue = Mock() + mock_queue.status.return_value = { + "status": { + "taskId": "fakeTaskId", + "state": "completed", + } + } + mock_queue.listTaskGroup.side_effect = mock_list_task_group + mock_get_service.return_value = mock_queue + mock_tasks_get_service.return_value = mock_queue + + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=initial_status, + task_id="fakeTaskId", + dry_run=True, + ) + db.session.add(automation) + db.session.commit() + + with app.test_client() as client: + response = client.get(f"/merge-automation/{automation.id}/task-status") + assert response.status_code == 200 + + data = response.json() + assert data["automation"]["id"] == automation.id + assert data["automation"]["status"] == initial_status.name.lower() + assert data["decisionTask"]["taskId"] == "fakeTaskId" + assert data["taskGroup"]["overallStatus"] == expected_task_group_status.name.lower() + + +def test_get_merge_automation_task_status_not_found(app): + with app.test_client() as client: + response = client.get("/merge-automation/999/task-status") + assert response.status_code == 404 + assert response.json()["detail"] == "Automation not found or no task ID" + + +def test_get_merge_automation_task_status_no_task_id(app): + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=TaskStatus.Pending, + task_id=None, + dry_run=True, + ) + db.session.add(automation) + db.session.commit() + + with app.test_client() as client: + response = client.get(f"/merge-automation/{automation.id}/task-status") + assert response.status_code == 404 + assert response.json()["detail"] == "Automation not found or no task ID" + + +@patch("shipit_api.admin.merge_automation.current_user") +def test_mark_merge_automation_completed(mock_user, app): + mock_user.has_permissions.return_value = True + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=TaskStatus.Running, + task_id="fakeTaskId", + dry_run=True, + ) + db.session.add(automation) + db.session.commit() + automation_id = automation.id + + with app.test_client() as client: + response = client.patch(f"/merge-automation/{automation_id}") + assert response.status_code == 200 + assert response.json()["status"] == "completed" + + updated_automation = db.session.get(MergeAutomation, automation_id) + assert updated_automation.status == TaskStatus.Completed + assert updated_automation.completed is not None + + +def test_mark_merge_automation_completed_not_found(app): + with app.test_client() as client: + response = client.patch("/merge-automation/999") + assert response.status_code == 404 + + +@pytest.mark.parametrize("initial_status", [TaskStatus.Completed, TaskStatus.Canceled]) +@patch("shipit_api.admin.merge_automation.current_user") +def test_mark_merge_automation_completed_already_terminal(mock_user, app, initial_status): + mock_user.has_permissions.return_value = True + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=initial_status, + task_id="fakeTaskId", + dry_run=True, + ) + db.session.add(automation) + db.session.commit() + + with app.test_client() as client: + response = client.patch(f"/merge-automation/{automation.id}") + assert response.status_code == 400 + assert f"Cannot update automation in {initial_status.name} status" in response.json()["detail"] + + +@pytest.mark.parametrize( + "task_id,task_state", + [ + ("task123", "completed"), + ("task456", "running"), + ], +) +@patch("shipit_api.admin.merge_automation.get_service") +def test_get_task_status(mock_get_service, app, task_id, task_state): + mock_queue = Mock() + mock_queue.status.return_value = { + "status": { + "taskId": task_id, + "state": task_state, + } + } + mock_get_service.return_value = mock_queue + + result = get_task_status(task_id) + + mock_get_service.assert_called_once_with("queue") + mock_queue.status.assert_called_once_with(task_id) + + assert result == { + "taskId": task_id, + "state": task_state, + } + + +@pytest.mark.parametrize( + "task_group_id,tasks,expected_status", + [ + ( + "group123", + [ + {"status": {"state": "completed"}}, + {"status": {"state": "completed"}}, + ], + TaskStatus.Completed, + ), + ( + "group456", + [ + {"status": {"state": "completed"}}, + {"status": {"state": "failed"}}, + {"status": {"state": "running"}}, + ], + TaskStatus.Failed, + ), + ( + "group789", + [ + {"status": {"state": "completed"}}, + {"status": {"state": "running"}}, + {"status": {"state": "completed"}}, + ], + TaskStatus.Running, + ), + ], +) +@patch("shipit_api.admin.tasks.get_service") +def test_get_task_group_status(mock_get_service, app, task_group_id, tasks, expected_status): + task_group_response = {"tasks": tasks} + + def mock_list_task_group(task_id, paginationHandler): + paginationHandler(task_group_response) + + mock_queue = Mock() + mock_queue.listTaskGroup.side_effect = mock_list_task_group + mock_get_service.return_value = mock_queue + + result = get_task_group_status(task_group_id) + + assert result == expected_status + + +# Permission tests +@patch("shipit_api.admin.merge_automation.current_user", new_callable=lambda: Mock()) +def test_submit_merge_automation_permission_denied(mock_user, app): + mock_user.has_permissions = Mock(return_value=False) + mock_user.get_permissions = Mock(return_value=["some:other:permission"]) + + with app.test_client() as client: + payload = { + "product": "firefox", + "behavior": "main-to-beta", + "revision": "abc123", + "dryRun": True, + "version": "130.0", + "commitMessage": "Test commit", + "commitAuthor": "test@example.com", + } + + response = client.post("/merge-automation", json=payload) + assert response.status_code == 401 + assert "required permission: project:releng:services/shipit_api/add_merge_automation/firefox" in response.json()["detail"] + assert "user permissions: some:other:permission" in response.json()["detail"] + + +@patch("shipit_api.admin.merge_automation.current_user", new_callable=lambda: Mock()) +def test_start_merge_automation_permission_denied(mock_user, app): + mock_user.has_permissions = Mock(return_value=False) + mock_user.get_permissions = Mock(return_value=["some:other:permission"]) + + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=TaskStatus.Pending, + dry_run=True, + ) + db.session.add(automation) + db.session.commit() + + with app.test_client() as client: + response = client.post(f"/merge-automation/{automation.id}/start") + assert response.status_code == 401 + assert "required permission: project:releng:services/shipit_api/add_merge_automation/firefox" in response.json()["detail"] + assert "user permissions: some:other:permission" in response.json()["detail"] + + +@patch("shipit_api.admin.merge_automation.current_user", new_callable=lambda: Mock()) +def test_cancel_merge_automation_permission_denied(mock_user, app): + mock_user.has_permissions = Mock(return_value=False) + mock_user.get_permissions = Mock(return_value=["some:other:permission"]) + + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=TaskStatus.Pending, + dry_run=True, + ) + db.session.add(automation) + db.session.commit() + + with app.test_client() as client: + response = client.delete(f"/merge-automation/{automation.id}") + assert response.status_code == 401 + assert "required permission: project:releng:services/shipit_api/cancel_merge_automation/firefox" in response.json()["detail"] + assert "user permissions: some:other:permission" in response.json()["detail"] + + +@patch("shipit_api.admin.merge_automation.current_user", new_callable=lambda: Mock()) +def test_mark_merge_automation_completed_permission_denied(mock_user, app): + mock_user.has_permissions = Mock(return_value=False) + mock_user.get_permissions = Mock(return_value=["some:other:permission"]) + + automation = create_merge_automation_with_defaults( + product="firefox", + behavior="main-to-beta", + revision="abc123", + version="130.0", + status=TaskStatus.Running, + dry_run=True, + ) + db.session.add(automation) + db.session.commit() + + with app.test_client() as client: + response = client.patch(f"/merge-automation/{automation.id}") + assert response.status_code == 401 + assert "required permission: project:releng:services/shipit_api/mark_merge_automation_completed/firefox" in response.json()["detail"] + assert "user permissions: some:other:permission" in response.json()["detail"] diff --git a/frontend/src/components/Dashboard/MergeAutomationMenu.jsx b/frontend/src/components/Dashboard/MergeAutomationMenu.jsx new file mode 100644 index 000000000..5e9ac3c79 --- /dev/null +++ b/frontend/src/components/Dashboard/MergeAutomationMenu.jsx @@ -0,0 +1,30 @@ +import { AddBox, List } from '@mui/icons-material'; +import SettingsOutlineIcon from '@mui/icons-material/MergeOutlined'; +import React from 'react'; +import DashboardMenu from './DashboardMenu'; +import { LinkMenuItem } from './MenuComponents'; + +function MergeAutomationMenu({ disabled }) { + return ( + } + menuId="merge-automation-menu" + ariaLabel="merge automation menu" + disabled={disabled} + > + } + text="New merge automation" + to="/merge-automation/new" + /> + } + text="List merge automation" + to="/merge-automation" + /> + + ); +} + +export default MergeAutomationMenu; diff --git a/frontend/src/components/Dashboard/index.jsx b/frontend/src/components/Dashboard/index.jsx index df9da43c0..4cb4728ec 100644 --- a/frontend/src/components/Dashboard/index.jsx +++ b/frontend/src/components/Dashboard/index.jsx @@ -1,4 +1,5 @@ import ExtensionIcon from '@mui/icons-material/Extension'; +import Alert from '@mui/material/Alert'; import AppBar from '@mui/material/AppBar'; import Box from '@mui/material/Box'; import Breadcrumbs from '@mui/material/Breadcrumbs'; @@ -7,11 +8,13 @@ import Paper from '@mui/material/Paper'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { bool, node, string } from 'prop-types'; -import React, { Fragment } from 'react'; +import React, { Fragment, useEffect, useState } from 'react'; +import { useLocation } from 'react-router'; import { makeStyles } from 'tss-react/mui'; import { DEPLOYMENT_BRANCH } from '../../config'; import { APP_BAR_HEIGHT, CONTENT_MAX_WIDTH } from '../../utils/constants'; import Footer from '../../views/Footer'; +import MergeAutomationMenu from './MergeAutomationMenu'; import ReleasesMenu from './ReleasesMenu'; import SettingsMenu from './SettingsMenu'; import UserMenu from './UserMenu'; @@ -102,6 +105,19 @@ function Logo(props) { export default function Dashboard(props) { const { classes } = useStyles(); const { title, children, disabled, group } = props; + const { state } = useLocation(); + const [successMessage, setSuccessMessage] = useState(state?.successMessage); + + useEffect(() => { + if (state?.successMessage) { + // Clear from history state so it doesn't persist on page refresh + window.history.replaceState({}, ''); + } + }, []); + + const handleSuccessClose = () => { + setSuccessMessage(null); + }; return ( @@ -129,6 +145,7 @@ export default function Dashboard(props) { /> )}