diff --git a/.circleci/config.yml b/.circleci/config.yml index 55ebd7ea..bf756707 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,7 +13,7 @@ version: 2 jobs: full: docker: - - image: cimg/base:2020.01 + - image: cimg/base:2024.05 user: root steps: - setup_remote_docker @@ -56,7 +56,7 @@ jobs: - run: name: Run the tests command: | - docker-compose \ + docker compose \ -f docker/docker-compose.ci.yml \ -p landoui \ run lando-ui pytest --junitxml=/test_results/junit.xml @@ -74,7 +74,7 @@ jobs: - store_artifacts: path: /tmp/test-reports - - deploy: + - run: command: | if [[ "x$DOCKERHUB_REPO" != x ]]; then docker login -u "$DOCKER_USER" -p "$DOCKER_PASS" @@ -85,11 +85,11 @@ jobs: docker tag "mozilla/landoui" "${DOCKERHUB_REPO}:${CIRCLE_TAG}" docker push "${DOCKERHUB_REPO}:${CIRCLE_TAG}" elif [[ ${CIRCLE_BRANCH} == develop ]]; then - docker tag "mozilla/landoui" "${DOCKERHUB_REPO}:develop-latest" - docker push "${DOCKERHUB_REPO}:develop-latest" + docker tag "mozilla/landoui" "${DOCKERHUB_REPO}:develop-${CIRCLE_SHA1}" + docker push "${DOCKERHUB_REPO}:develop-${CIRCLE_SHA1}" elif [[ ${CIRCLE_BRANCH} == staging ]]; then - docker tag "mozilla/landoui" "${DOCKERHUB_REPO}:staging-latest" - docker push "${DOCKERHUB_REPO}:staging-latest" + docker tag "mozilla/landoui" "${DOCKERHUB_REPO}:staging-${CIRCLE_SHA1}" + docker push "${DOCKERHUB_REPO}:staging-${CIRCLE_SHA1}" fi fi diff --git a/landoui/app.py b/landoui/app.py index b086ec78..3d754a8e 100644 --- a/landoui/app.py +++ b/landoui/app.py @@ -54,6 +54,7 @@ def create_app( use_https: bool, enable_asset_pipeline: bool, lando_api_url: str, + treestatus_url: str, debug: bool = False, ) -> Flask: """ @@ -85,6 +86,7 @@ def create_app( initialize_sentry(version_info["version"]) set_config_param(app, "LANDO_API_URL", lando_api_url) + set_config_param(app, "TREESTATUS_URL", treestatus_url) set_config_param( app, "BUGZILLA_URL", _lookup_service_url(lando_api_url, "bugzilla") ) @@ -121,10 +123,12 @@ def create_app( from landoui.pages import pages from landoui.revisions import revisions from landoui.dockerflow import dockerflow + from landoui.treestatus import treestatus_blueprint app.register_blueprint(pages) app.register_blueprint(revisions) app.register_blueprint(dockerflow) + app.register_blueprint(treestatus_blueprint) # Register template helpers from landoui.template_helpers import template_helpers diff --git a/landoui/assets_app.py b/landoui/assets_app.py index 5ed12db0..66eec5c0 100644 --- a/landoui/assets_app.py +++ b/landoui/assets_app.py @@ -22,5 +22,6 @@ use_https=False, enable_asset_pipeline=True, lando_api_url="http://lando-api.test", + treestatus_url="http://treestatus.test", debug=True, ) diff --git a/landoui/assets_src/css/pages/TreestatusPage.scss b/landoui/assets_src/css/pages/TreestatusPage.scss new file mode 100644 index 00000000..e1669371 --- /dev/null +++ b/landoui/assets_src/css/pages/TreestatusPage.scss @@ -0,0 +1,27 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +@import "../colors"; + +.recent-changes-update-hidden { + // Hide the hidden elements in the recent changes view. + display: none; +} + +.log-update-hidden { + // Hide the hidden elements in the log update view. + display: none; +} + +.select-trees-box { + // Remove the spaces between tree boxes. + margin-bottom: 0rem !important; +} + +.tree-category-header { + // Add some padding category header elements. + padding-top: 1rem; +} diff --git a/landoui/assets_src/js/components/Stack.js b/landoui/assets_src/js/components/Stack.js index eb45a76e..6e92e7d8 100644 --- a/landoui/assets_src/js/components/Stack.js +++ b/landoui/assets_src/js/components/Stack.js @@ -15,5 +15,13 @@ $.fn.stack = function() { window.location.href = '/' + e.target.id; $radio.attr({'disabled': true}); }); + + // Show the uplift request form modal when the "Request Uplift" button is clicked. + $('.uplift-request-open').on("click", function () { + $('.uplift-request-modal').addClass("is-active"); + }); + $('.uplift-request-close').on("click", function () { + $('.uplift-request-modal').removeClass("is-active"); + }); }); }; diff --git a/landoui/assets_src/js/components/Treestatus.js b/landoui/assets_src/js/components/Treestatus.js new file mode 100644 index 00000000..e49d28d7 --- /dev/null +++ b/landoui/assets_src/js/components/Treestatus.js @@ -0,0 +1,80 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +'use strict'; + +$.fn.treestatus = function() { + return this.each(function() { + // Register an on-click handler for each log update edit button. + $('.log-update-edit').on("click", function () { + // Toggle the elements from hidden/visible. + var closest_form = $(this).closest('.log-update-form'); + + closest_form.find('.log-update-hidden').toggle(); + closest_form.find('.log-update-visible').toggle(); + }); + + // Register an on-click handler for each recent changes edit button. + $('.recent-changes-edit').on("click", function () { + // Toggle the elements from hidden/visible. + var closest_form = $(this).closest('.recent-changes-form'); + + closest_form.find('.recent-changes-update-hidden').toggle(); + closest_form.find('.recent-changes-update-visible').toggle(); + }); + + // Toggle selected on all trees. + $('.select-all-trees').on("click", function () { + var checkboxes = $('.tree-select-checkbox'); + checkboxes.prop('checked', true); + checkboxes.trigger('change'); + }); + + // Toggle un-selected on all trees. + $('.unselect-all-trees').on("click", function () { + var checkboxes = $('.tree-select-checkbox'); + checkboxes.prop('checked', false); + checkboxes.trigger('change'); + }); + + // Update the select trees list after update. + var set_update_trees_list = function () { + // Clear the current state of the update form tree list. + var trees_list = $('.update-trees-list') + trees_list.empty(); + + // Get all the checked boxes in the select trees view. + $('.tree-select-checkbox:checked').each(function () { + var checkbox = $(this); + + // Add a new `li` element for each selected tree. + trees_list.append( + $('<li></li>').text(checkbox.val()) + ); + }); + }; + + // Show the update trees modal when "Update trees" is clicked. + $('.update-trees-button').on("click", function () { + $('.update-trees-modal').toggle(); + }); + + // Close the update trees modal when the close button is clicked. + $('.update-trees-modal-close').on("click", function () { + $('.update-trees-modal').toggle(); + }); + + // Add a tree to the list of trees on the update form when checkbox set. + $('.tree-select-checkbox').on("change", function () { + set_update_trees_list(); + + var checked_trees = $('.tree-select-checkbox:checked'); + // Disaable the "Update trees" button when no trees are selected. + var is_tree_select_disabled = checked_trees.length > 0 ? false : true; + $('.update-trees-button').prop('disabled', is_tree_select_disabled); + }); + }); +}; diff --git a/landoui/assets_src/js/main.js b/landoui/assets_src/js/main.js index fbecdfd7..2d58e003 100644 --- a/landoui/assets_src/js/main.js +++ b/landoui/assets_src/js/main.js @@ -12,6 +12,7 @@ $(document).ready(function() { let $stack = $('.StackPage-stack'); let $secRequestSubmitted = $('.StackPage-secRequestSubmitted'); let $flashMessages = $('.FlashMessages'); + let $treestatus = $('.Treestatus'); // Initialize components $('.Navbar').landoNavbar(); @@ -20,4 +21,5 @@ $(document).ready(function() { $stack.stack(); $secRequestSubmitted.secRequestSubmitted(); $flashMessages.flashMessages(); + $treestatus.treestatus(); }); diff --git a/landoui/dev_app.py b/landoui/dev_app.py index e9dcc5b1..3c6ab66a 100644 --- a/landoui/dev_app.py +++ b/landoui/dev_app.py @@ -22,6 +22,7 @@ def create_dev_app(**kwargs): "use_https": True, "enable_asset_pipeline": True, "lando_api_url": None, + "treestatus_url": None, } # These are parameters that should be converted to a boolean value. diff --git a/landoui/forms.py b/landoui/forms.py index 49cdce2e..ce9e9191 100644 --- a/landoui/forms.py +++ b/landoui/forms.py @@ -1,8 +1,11 @@ # This Source Code Form is subject to the terms of the Mozilla Public # 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 enum import json +from dataclasses import dataclass from json.decoder import JSONDecodeError from typing import ( Optional, @@ -13,13 +16,19 @@ from wtforms import ( BooleanField, Field, + FieldList, HiddenField, SelectField, StringField, + SubmitField, TextAreaField, ValidationError, ) -from wtforms.validators import InputRequired, optional, Regexp +from wtforms.validators import ( + InputRequired, + Regexp, + optional, +) class JSONDecodable: @@ -120,3 +129,264 @@ class UserSettingsForm(FlaskForm): ], ) reset_phab_api_token = BooleanField("Delete", default="") + + +class Status(enum.Enum): + """Allowable statuses of a tree.""" + + OPEN = "open" + CLOSED = "closed" + APPROVAL_REQUIRED = "approval required" + + @classmethod + def to_choices(cls) -> list[tuple[str, str]]: + """Return a list of choices for display.""" + return [(choice.value, choice.value.capitalize()) for choice in list(cls)] + + +class ReasonCategory(enum.Enum): + """Allowable reasons for a Tree closure.""" + + NO_CATEGORY = "" + JOB_BACKLOG = "backlog" + CHECKIN_COMPILE_FAILURE = "checkin_compilation" + CHECKIN_TEST_FAILURE = "checkin_test" + PLANNED_CLOSURE = "planned" + MERGES = "merges" + WAITING_FOR_COVERAGE = "waiting_for_coverage" + INFRASTRUCTURE_RELATED = "infra" + OTHER = "other" + + @classmethod + def to_choices(cls) -> list[tuple[str, str]]: + """Return a list of choices for display.""" + return [(choice.value, choice.to_display()) for choice in list(cls)] + + def to_display(self) -> str: + """Return a human-readable version of the category.""" + return { + ReasonCategory.NO_CATEGORY: "No Category", + ReasonCategory.JOB_BACKLOG: "Job Backlog", + ReasonCategory.CHECKIN_COMPILE_FAILURE: "Check-in compilation failure", + ReasonCategory.CHECKIN_TEST_FAILURE: "Check-in test failure", + ReasonCategory.PLANNED_CLOSURE: "Planned closure", + ReasonCategory.MERGES: "Merges", + ReasonCategory.WAITING_FOR_COVERAGE: "Waiting for coverage", + ReasonCategory.INFRASTRUCTURE_RELATED: "Infrastructure related", + ReasonCategory.OTHER: "Other", + }[self] + + @classmethod + def is_valid_for_backend(cls, value) -> bool: + """Return `True` if `value` is a valid `ReasonCategory` to be submitted. + + All `ReasonCategory` members are valid except for `NO_CATEGORY` as that is + implied by an empty `tags` key in the backend. + """ + try: + category = cls(value) + except ValueError: + return False + + if category == ReasonCategory.NO_CATEGORY: + return False + + return True + + +def build_update_json_body( + reason: Optional[str], reason_category: Optional[str] +) -> dict: + """Return a `dict` for use as a JSON body in a log/change update.""" + json_body = {} + + json_body["reason"] = reason + + if reason_category and ReasonCategory.is_valid_for_backend(reason_category): + json_body["tags"] = [reason_category] + + return json_body + + +class TreeStatusUpdateTreesForm(FlaskForm): + """Form used to update the state of a selection of trees.""" + + trees = FieldList( + StringField( + "Trees", + validators=[ + InputRequired("A selection of trees is required to update statuses.") + ], + ) + ) + + status = SelectField( + "Status", + choices=Status.to_choices(), + validators=[InputRequired("A status is required.")], + ) + + reason = StringField("Reason") + + reason_category = SelectField( + "Reason Category", + choices=ReasonCategory.to_choices(), + default=ReasonCategory.NO_CATEGORY.value, + ) + + remember = BooleanField( + "Remember this change", + default=True, + ) + + message_of_the_day = StringField("Message of the day") + + def validate_trees(self, field): + """Validate that at least 1 tree was selected.""" + if not field.entries: + raise ValidationError( + "A selection of trees is required to update statuses." + ) + + def validate_reason(self, field): + """Validate that the reason field is required for non-open statuses.""" + reason_is_empty = not field.data + + if Status(self.status.data) == Status.CLOSED and reason_is_empty: + raise ValidationError("Reason description is required to close trees.") + + def validate_reason_category(self, field): + """Validate that the reason category field is required for non-open statuses.""" + try: + category_is_empty = ( + not field.data + or ReasonCategory(field.data) == ReasonCategory.NO_CATEGORY + ) + except ValueError: + raise ValidationError("Reason category is an invalid value.") + + if Status(self.status.data) == Status.CLOSED and category_is_empty: + raise ValidationError("Reason category is required to close trees.") + + def to_submitted_json(self) -> dict: + """Convert a validated form to JSON for submission to LandoAPI.""" + # Avoid setting tags for invalid values. + tags = ( + [self.reason_category.data] + if ReasonCategory.is_valid_for_backend(self.reason_category.data) + else [] + ) + + return { + "trees": self.trees.data, + "status": self.status.data, + "reason": self.reason.data, + "message_of_the_day": self.message_of_the_day.data, + "tags": tags, + "remember": self.remember.data, + } + + +class TreeCategory(enum.Enum): + """Categories of the various trees. + + Note: the definition order is in order of importance for display in the UI. + Note: this class also exists in Lando-UI, and should be updated in both places. + """ + + DEVELOPMENT = "development" + RELEASE_STABILIZATION = "release_stabilization" + TRY = "try" + COMM_REPOS = "comm_repos" + OTHER = "other" + + @classmethod + def sort_trees(cls, item: dict) -> int: + """Key function for sorting tree `dict`s according to category order.""" + return [choice.value for choice in list(cls)].index(item["category"]) + + @classmethod + def to_choices(cls) -> list[tuple[str, str]]: + """Return a list of choices for display.""" + return [(choice.value, choice.to_display()) for choice in list(cls)] + + def to_display(self) -> str: + """Return a human readable version of the category.""" + return " ".join(word.capitalize() for word in self.value.split("_")) + + +class TreeStatusNewTreeForm(FlaskForm): + """Add a new tree to Treestatus.""" + + tree = StringField( + "Tree", + validators=[InputRequired("A tree name is required.")], + ) + + category = SelectField( + "Tree category", + choices=TreeCategory.to_choices(), + default=TreeCategory.OTHER.value, + ) + + +@dataclass +class RecentChangesAction: + method: str + request_args: dict + message: str + + +class TreeStatusRecentChangesForm(FlaskForm): + """Modify a recent status change.""" + + id = HiddenField("Id") + + reason = StringField("Reason") + + reason_category = SelectField( + "Reason Category", + choices=ReasonCategory.to_choices(), + ) + + restore = SubmitField("Restore") + + update = SubmitField("Update") + + discard = SubmitField("Discard") + + def to_action(self) -> RecentChangesAction: + """Return a `RecentChangesAction` describing interaction with Lando-API.""" + if self.update.data: + # Update is a PATCH with any changed attributes passed in the body. + return RecentChangesAction( + method="PATCH", + request_args={ + "json": build_update_json_body( + self.reason.data, self.reason_category.data + ) + }, + message="Status change updated.", + ) + + revert = 1 if self.restore.data else 0 + message = f"Status change {'restored' if self.restore.data else 'discarded'}." + + return RecentChangesAction( + method="DELETE", + request_args={"params": {"revert": revert}}, + message=message, + ) + + +class TreeStatusLogUpdateForm(FlaskForm): + """Modify a log entry.""" + + id = HiddenField("Id") + + reason = StringField("Reason") + + reason_category = SelectField( + "Reason Category", + choices=ReasonCategory.to_choices(), + ) diff --git a/landoui/landoapi.py b/landoui/landoapi.py index 127e9739..e1580844 100644 --- a/landoui/landoapi.py +++ b/landoui/landoapi.py @@ -22,8 +22,8 @@ logger = logging.getLogger(__name__) -class LandoAPI: - """Client for Lando API.""" +class API: + """Common components of a Lando-based API.""" def __init__( self, @@ -31,13 +31,17 @@ def __init__( *, phabricator_api_token: Optional[str] = None, auth0_access_token: Optional[str] = None, - session: Optional[requests.Session] = None + session: Optional[requests.Session] = None, ): self.url = url + "/" if url[-1] == "/" else url + "/" self.phabricator_api_token = phabricator_api_token self.auth0_access_token = auth0_access_token self.session = session or self.create_session() + @property + def service_name(self): + raise NotImplementedError + @staticmethod def create_session() -> requests.Session: return requests.Session() @@ -82,7 +86,7 @@ def request( response = self.session.request(method, self.url + url_path, **kwargs) logger.debug( - "lando-api response", + f"{self.service_name} response", extra={ "status_code": response.status_code, "content_type": response.headers.get("Content-Type"), @@ -106,8 +110,16 @@ def request( LandoAPIError.raise_if_error(response, data) return data + +class LandoAPI(API): + """Client for LandoAPI.""" + + @property + def service_name(self) -> str: + return "LandoAPI" + @classmethod - def from_environment(cls, token: Optional[str] = None) -> LandoAPI: + def from_environment(cls, token: Optional[str] = None) -> API: """Build a `LandoAPI` object from the environment.""" if not token: token = get_phabricator_api_token() @@ -119,6 +131,22 @@ def from_environment(cls, token: Optional[str] = None) -> LandoAPI: ) +class TreestatusAPI(API): + """Client for Treestatus.""" + + @property + def service_name(self) -> str: + return "Treestatus" + + @classmethod + def from_environment(cls) -> API: + """Build a `TreestatusAPI` object from the environment.""" + return cls( + current_app.config["TREESTATUS_URL"], + auth0_access_token=session.get("access_token"), + ) + + class LandoAPIException(Exception): """Exception from LandoAPI.""" diff --git a/landoui/template_helpers.py b/landoui/template_helpers.py index b472f923..c9443e18 100644 --- a/landoui/template_helpers.py +++ b/landoui/template_helpers.py @@ -11,8 +11,12 @@ from typing import Optional -from flask import Blueprint, current_app, escape -from landoui.forms import UserSettingsForm +from flask import Blueprint, current_app, escape, session +from landoui.forms import ( + ReasonCategory, + TreeCategory, + UserSettingsForm, +) from landoui import helpers @@ -28,6 +32,25 @@ def is_user_authenticated() -> bool: return helpers.is_user_authenticated() +TREESTATUS_USER_GROUPS = { + "mozilliansorg_treestatus_admins", + "mozilliansorg_treestatus_users", +} + + +@template_helpers.app_template_global() +def is_treestatus_user() -> bool: + if not is_user_authenticated(): + return False + + try: + groups = session["userinfo"]["https://sso.mozilla.com/claim/groups"] + except KeyError: + return False + + return not TREESTATUS_USER_GROUPS.isdisjoint(groups) + + @template_helpers.app_template_global() def user_has_phabricator_token() -> bool: return helpers.get_phabricator_api_token() is not None @@ -92,6 +115,16 @@ def reviewer_to_status_badge_class(reviewer: dict) -> str: ] +@template_helpers.app_template_filter() +def treestatus_to_status_badge_class(tree_status: str) -> str: + """Convert Tree statuses into status badges.""" + return { + "open": "Badge Badge--positive", + "closed": "Badge Badge--negative", + "approval required": "Badge Badge--warning", + }.get(tree_status, "Badge Badge--warning") + + @template_helpers.app_template_filter() def reviewer_to_action_text(reviewer: dict) -> str: options = { @@ -130,6 +163,23 @@ def tostatusbadgename(status: dict) -> str: return mapping.get(status["status"].lower(), status["status"].capitalize()) +@template_helpers.app_template_filter() +def reason_category_to_display(reason_category_str: str) -> str: + try: + return ReasonCategory(reason_category_str).to_display() + except ValueError: + # Return the bare string, in case of older data. + return reason_category_str + + +@template_helpers.app_template_filter() +def tree_category_to_display(tree_category_str: str) -> str: + try: + return TreeCategory(tree_category_str).to_display() + except ValueError: + return tree_category_str + + @template_helpers.app_template_filter() def avatar_url(url: str) -> str: # If a user doesn't have a gravatar image for their auth0 email address, @@ -324,6 +374,9 @@ def message_type_to_notification_class(flash_message_category: str) -> str: See https://bulma.io/documentation/elements/notification/ for the list of Bulma notification states. """ - return {"info": "is-info", "success": "is-success", "warning": "is-warning"}.get( - flash_message_category, "is-info" - ) + return { + "error": "is-danger", + "info": "is-info", + "success": "is-success", + "warning": "is-warning", + }.get(flash_message_category, "is-info") diff --git a/landoui/templates/partials/navbar.html b/landoui/templates/partials/navbar.html index 4a33835c..79557138 100644 --- a/landoui/templates/partials/navbar.html +++ b/landoui/templates/partials/navbar.html @@ -25,6 +25,12 @@ <div class="navbar-end"> <div class="navbar-item"> <div class="field is-grouped"> + <p class="control"> + <a class="button" href="{{ url_for("treestatus.treestatus") }}"> + <span class="icon"><i class="fa fa-tree"></i></span> + <span>Treestatus</span> + </a> + </p> <p class="control"> <a class="button" href="{{ config['PHABRICATOR_URL'] }}"> <span class="icon"> diff --git a/landoui/templates/stack/partials/landing-preview.html b/landoui/templates/stack/partials/landing-preview.html index 7a1ace6c..0d27edf9 100644 --- a/landoui/templates/stack/partials/landing-preview.html +++ b/landoui/templates/stack/partials/landing-preview.html @@ -9,10 +9,11 @@ <h3 class="StackPage-landingPreview-sectionLabel">Landing is Blocked</h3> <div class="StackPage-landingPreview-section StackPage-landingPreview-blocker"> Reason for blockage is unknown </div> -{% elif dryrun['blocker'] %} +{% elif dryrun['blocker'] or dryrun['blockers'] %} + {% set blocker = dryrun['blockers'][0] if 'blockers' in dryrun else dryrun['blocker'] %} <h3 class="StackPage-landingPreview-sectionLabel">Landing is Blocked</h3> <div class="StackPage-landingPreview-section StackPage-landingPreview-blocker"> - {{ dryrun['blocker']|escape_html|linkify_faq|safe }} + {{ blocker|escape_html|linkify_faq|safe }} </div> {% elif series %} <h3 class="StackPage-landingPreview-sectionLabel"> diff --git a/landoui/templates/stack/partials/revision-status.html b/landoui/templates/stack/partials/revision-status.html index 01fb7115..8d09f975 100644 --- a/landoui/templates/stack/partials/revision-status.html +++ b/landoui/templates/stack/partials/revision-status.html @@ -4,7 +4,8 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/. #} -{% if revision['blocked_reason'] %} +{% if revision['blocked_reasons'] or revision['blocked_reason'] %} +{% set blocked_reason = revision['blocked_reasons'][0] if 'blocked_reasons' in revision else revision['blocked_reason']%} <div class="StackPage-blockerReason"> <div class="Badge Badge--negative"> Blocked @@ -13,7 +14,7 @@ </span> </div> <div class="StackPage-blockerReason-tooltip"> - {{revision['blocked_reason']|escape_html|linkify_faq|safe}} + {{blocked_reason|escape_html|linkify_faq|safe}} </div> </div> {% endif %} diff --git a/landoui/templates/stack/stack.html b/landoui/templates/stack/stack.html index 388b29ab..7702777c 100644 --- a/landoui/templates/stack/stack.html +++ b/landoui/templates/stack/stack.html @@ -104,23 +104,30 @@ <h2>Landing is blocked:</h2> {% endif %} </div> - <div class="StackPage-actions"> - {% if not is_user_authenticated() %} - <button disabled> - <div class="StackPage-actions-headline">Preview Landing</div> - <div class="StackPage-actions-subtitle">You must log in first</div> - </button> - {% elif not user_has_phabricator_token() and (not series or dryrun is none) %} - <button disabled> - <div class="StackPage-actions-headline">Landing Blocked</div> - <div class="StackPage-actions-subtitle">This revision is blocked from landing</div> - </button> - {% else %} - <button class="StackPage-preview-button"> - <div class="StackPage-actions-headline">Preview Landing</div> - </button> - {% endif %} + {% set can_uplift_revision = is_user_authenticated() and user_has_phabricator_token() %} + <div class="StackPage-actions"> + {% if not is_user_authenticated() %} + <button disabled> + <div class="StackPage-actions-headline">Preview Landing</div> + <div class="StackPage-actions-subtitle">You must log in first</div> + </button> + {% elif not series or dryrun is none %} + <button disabled> + <div class="StackPage-actions-headline">Landing Blocked</div> + <div class="StackPage-actions-subtitle">This revision is blocked from landing</div> + </button> + {% else %} + <button class="StackPage-preview-button"> + <div class="StackPage-actions-headline">Preview Landing</div> + </button> + {% endif %} + {% if can_uplift_revision %} + <button class="button uplift-request-open is-normal"> + <span class="icon"><i class="fa fa-arrow-circle-up"></i></span> + <div class="StackPage-actions-headline">Request Uplift</span> + </button> + {% endif %} </div> {% if is_user_authenticated() %} @@ -148,23 +155,6 @@ <h2>Landing is blocked:</h2> </button> <button class="StackPage-landingPreview-close button">Cancel</button> </form> - {% if user_has_phabricator_token() %} - <form class="StackPage-landingPreview-uplift" action="{{ url_for('revisions.uplift') }}" method="post"> - {{ uplift_request_form.csrf_token }} - <input type="hidden" name="revision_id" value="{{ revision_id }}" /> - {{ uplift_request_form.repository }} - <button - class="button" - title="Create Phabricator review requests for the selected patch stack to land in the specified uplift train." > - Request uplift - </button> - </form> - <a class="button" target="_blank" href="https://wiki.mozilla.org/index.php?title=Release_Management/Requesting_an_Uplift"> - <span class="icon"> - <i class="fa fa-question-circle"></i> - </span> - </a> - {% endif %} </footer> </div> </div> @@ -187,5 +177,60 @@ <h2>Landing is blocked:</h2> </div> </div> + {% if can_uplift_revision %} + <div class="uplift-request-modal modal"> + <form action="{{ url_for('revisions.uplift') }}" method="post"> + + <div class="modal-background"></div> + + <div class="modal-card"> + {{ uplift_request_form.csrf_token }} + + <header class="modal-card-head"> + <p class="modal-card-title">Request uplift</p> + <button class="uplift-request-close delete" aria-label="close" type="button"></button> + </header> + + <section class="modal-card-body"> + <input type="hidden" name="revision_id" value="{{ revision_id }}" /> + + <p class="block"> + Select the repository you wish to uplift this revision to. + Once you submit the request, you will be redirected to the new uplift revision on Phabricator. + Scroll to the bottom of the page, select "Change uplift request form" and complete the form. + Your revision will be reviewed and landed by a release manager. + </p> + + <div class="block"> + <a class="button" target="_blank" type="button" href="https://wiki.mozilla.org/index.php?title=Release_Management/Requesting_an_Uplift"> + <span class="icon"> + <i class="fa fa-question-circle"></i> + </span> + <span>Uplift request documentation</span> + </a> + </div> + + <div class="field"> + <label class="label">Uplift repository</label> + <div class="control"> + <div class="select"> + {{ uplift_request_form.repository }} + </div> + </div> + </div> + </section> + + <footer class="modal-card-foot"> + <button + class="button is-success" + title="Create Phabricator review requests for the selected patch stack to land in the specified uplift train." > + Create uplift request + </button> + </footer> + + </form> + </div> + {% endif %} + </main> {% endblock %} diff --git a/landoui/templates/treestatus/log.html b/landoui/templates/treestatus/log.html new file mode 100644 index 00000000..8926fd11 --- /dev/null +++ b/landoui/templates/treestatus/log.html @@ -0,0 +1,69 @@ +{# + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. +#} + +{% extends "partials/layout.html" %} +{% block page_title %}Treestatus{% endblock %} + +{% block main %} +<main class="Treestatus container fullhd"> + {% include "treestatus/recent_changes.html" %} + + <h1>Treestatus: {{ tree }}</h1> + <p>Current status: {{ tree }} is <span class="{{ current_log.status | treestatus_to_status_badge_class }}">{{ current_log.status }}</span></p> + + <div class="block"> + <a href="{{ url_for("treestatus.treestatus") }}"><button class="button">Show All Trees</button></a> + </div> + <div class="container"> + {% for log_update_form, log in logs %} + <form action="{{ url_for("treestatus.update_log", id=log.id) }}" method="post"> + {{ log_update_form.csrf_token }} + + <div class="box log-update-form"> + <div class="columns"> + <div class="column is-2"> + <span class="{{ log.status | treestatus_to_status_badge_class }}">{{ log.status }}</span> + </div> + <div class="column is-expanded"> + <div class="block"><b>{{ log.when }}</b></div> + <div class="block"><h2 class="subtitle">{{ log.who }}</h2></div> + <div class="block"> + {% if log.reason %} + <div class="log-update-visible"> + <p>{{ log_update_form.reason.label }}: <b>{{ log.reason }}</b></p> + <p>{{ log_update_form.reason_category.label }}: <b>{{ log_update_form.reason_category.data | reason_category_to_display }}</b></p> + </div> + {% endif %} + <div class="log-update-hidden"> + <div class="field"> + <label class="label">{{ log_update_form.reason.label }}</label> + {{ log_update_form.reason(id=id_string, class="input") }} + </div> + <div class="field"> + <label class="label">{{ log_update_form.reason_category.label }}</label> + {{ log_update_form.reason_category(id=id_string, class="select") }} + </div> + </div> + </div> + </div> + <div class="column is-narrow"> + {% if is_user_authenticated() %} + <div class="log-update-visible"> + <button class="button is-small is-primary log-update-edit" type="button">Edit</button> + </div> + {% endif %} + <div class="log-update-hidden"> + <button class="button delete is-normal log-update-edit" type="button"></button> + <button class="button is-small is-primary">Update</button> + </div> + </div> + </div> + </div> + </form> + {% endfor %} + </div> +</main> +{% endblock %} diff --git a/landoui/templates/treestatus/new_tree.html b/landoui/templates/treestatus/new_tree.html new file mode 100644 index 00000000..2af79743 --- /dev/null +++ b/landoui/templates/treestatus/new_tree.html @@ -0,0 +1,35 @@ +{# + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. +#} + +{% extends "partials/layout.html" %} +{% block page_title %}Treestatus{% endblock %} + +{% block main %} +<main class="Treestatus container fullhd"> + {% include "treestatus/recent_changes.html" %} + + <h1>Treestatus: New tree</h1> + <form action="{{ url_for("treestatus.new_tree") }}" method="post"> + {{ treestatus_new_tree_form.csrf_token }} + <div class="box"> + <div class="field"> + <label class="label">{{ treestatus_new_tree_form.tree.label }}</label> + <div class="control is-expanded"> + {{ treestatus_new_tree_form.tree(class="input", placeholder="New tree name") }} + </div> + <p class="help">The status will be "open" by default.</p> + </div> + <div class="field"> + <label class="label">{{ treestatus_new_tree_form.category.label }}</label> + <div class="control is-expanded"> + <div class="select is-fullwidth">{{ treestatus_new_tree_form.category() }}</div> + </div> + </div> + <button class="button is-primary">Add tree</button> + </div> + </form> +</main> +{% endblock %} diff --git a/landoui/templates/treestatus/recent_changes.html b/landoui/templates/treestatus/recent_changes.html new file mode 100644 index 00000000..b2b1ed8a --- /dev/null +++ b/landoui/templates/treestatus/recent_changes.html @@ -0,0 +1,65 @@ +{# + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. +#} + +{# Only render the recent changes header if there are any changes available. #} +{% if recent_changes_stack and is_user_authenticated() %} +<h1>Recent changes</h1> +<div class="container"> + {% for status_change_form, status_change_data in recent_changes_stack %} + <div class="box"> + <form class="recent-changes-form" action="{{ url_for("treestatus.update_change", id=status_change_data.id) }}" method="post"> + {{ status_change_form.csrf_token }} + + <div class="columns"> + <div class="column"> + <p>At {{ status_change_data.when }}, <b>{{ status_change_data.who }}</b> changed trees:</p> + <div class="block content"> + <ul> + {% for tree in status_change_data.trees %} + <li><b>{{ tree.tree }}</b> + from <span class="{{ tree.last_state.status | treestatus_to_status_badge_class }}"> + {{ tree.last_state.status }} + </span> + to <span class="{{ tree.last_state.current_status | treestatus_to_status_badge_class }}"> + {{ tree.last_state.current_status }} + </span> + </li> + {% endfor %} + </ul> + </div> + <div class="recent-changes-update-visible"> + <p>{{ status_change_form.reason.label }}: <b>{{ status_change_form.reason.data }}</b></p> + <p>{{ status_change_form.reason_category.label }}: <b>{{ status_change_form.reason_category.data | reason_category_to_display }}</b></p> + </div> + <div class="recent-changes-update-hidden"> + <div class="field"> + <label class="label">{{ status_change_form.reason.label }}</label> + <div class="control">{{ status_change_form.reason }}</div> + </div> + <div class="field"> + <label class="label">{{ status_change_form.reason_category.label }}</label> + <div class="control">{{ status_change_form.reason_category }}</div> + </div> + </div> + </div> + <div class="column is-narrow"> + <div class="recent-changes-update-visible"> + {{ status_change_form.restore(class_="button is-success is-small") }} + <button class="button is-info is-small recent-changes-edit" type="button">Edit</button> + {{ status_change_form.discard(class_="button is-danger is-small") }} + </div> + <div class="recent-changes-update-hidden"> + <button class="button delete is-normal recent-changes-edit" type="button"></button> + {{ status_change_form.update(class_="button is-info is-small") }} + </div> + </div> + </div> + </form> + </div> + {% endfor %} +</div> +</br> +{% endif %} diff --git a/landoui/templates/treestatus/trees.html b/landoui/templates/treestatus/trees.html new file mode 100644 index 00000000..30439f19 --- /dev/null +++ b/landoui/templates/treestatus/trees.html @@ -0,0 +1,81 @@ +{# + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. +#} + +{% extends "partials/layout.html" %} +{% block page_title %}Treestatus{% endblock %} + +{% block main %} +<main class="Treestatus container fullhd"> + <h1>Treestatus</h1> + <p>Current status of Mozilla's version-control repositories.</p> + + {% if is_treestatus_user() %} + {% include "treestatus/recent_changes.html" %} + {% endif %} + + <h1>Trees</h1> + {# + The main Treestatus page is a table that presents the trees. + Trees can be selected here for updating, or for deletion. + #} + <form method="post"> + {{ treestatus_update_trees_form.csrf_token }} + + {% if is_treestatus_user() %} + <div class="block"> + <a href="{{ url_for("treestatus.new_tree") }}"> + <button class="button" title="New Tree" type="button">New Tree</button> + </a> + <button class="button select-all-trees" type="button">Select all trees</button> + <button class="button unselect-all-trees" type="button">Unselect all trees</button> + <button class="button is-primary update-trees-button" title="Update Tree" type="button" disabled="disabled">Update trees</button> + </div> + {% endif %} + + {# Create a namespace to track the current category across the loop iterations. #} + {% set ns = namespace(current_category="") %} + + {% for tree_option in treestatus_update_trees_form.trees %} + {% set tree = trees[tree_option.data] %} + + {# Check if the category has changed and display a header for new category. #} + {% if tree.category != ns.current_category %} + {% set ns.current_category = tree.category %} + <h4 class="subtitle is-4 tree-category-header">{{ ns.current_category | tree_category_to_display }}</h1> + {% endif %} + + <div class="select-trees-box box"> + <div class="columns"> + {% if is_treestatus_user() %} + <div class="column is-1"> + <input class="tree-select-checkbox" type="checkbox" name="{{ tree_option.id }}" value="{{ tree_option.data }}"> + </div> + {% endif %} + <div class="column is-2"> + <span class="{{ tree.status | treestatus_to_status_badge_class }}">{{ tree.status }}</span> + </div> + <div class="column"> + <a href="{{ tree_option.data }}"><h2 class="subtitle is-4">{{ tree_option.data }}</h2></a> + </div> + <div class="column"> + {% if tree.reason %} + <p>Reason: <b>{{ tree.reason }}</b></p> + <p>Reason category: <b>{{ tree.tags[0] | reason_category_to_display }}</b></p> + {% endif %} + </div> + <div class="column"> + {% if tree.message_of_the_day %} + {{ tree.message_of_the_day }} + {% endif %} + </div> + </div> + </div> + {% endfor %} + + {% include "treestatus/update_trees.html" %} + </form> +</main> +{% endblock %} diff --git a/landoui/templates/treestatus/update_trees.html b/landoui/templates/treestatus/update_trees.html new file mode 100644 index 00000000..8420bb69 --- /dev/null +++ b/landoui/templates/treestatus/update_trees.html @@ -0,0 +1,57 @@ +{# + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. +#} + +{% block main %} +<div class="modal update-trees-modal"> + <div class="modal-background"></div> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title">Update trees</p> + </header> + <section class="modal-card-body"> + {{ treestatus_update_trees_form.csrf_token }} + <div class="block content"> + <div>You are about to update the following trees:</div> + <ul class="update-trees-list"></ul> + </div> + <div class="field"> + <label class="label">{{ treestatus_update_trees_form.status.label }}</label> + <div class="control is-expanded"> + <div class="select is-fullwidth">{{ treestatus_update_trees_form.status() }}</div> + </div> + </div> + <div class="field"> + <label class="label">{{ treestatus_update_trees_form.reason_category.label }}</label> + <div class="control is-expanded"> + <div class="select is-fullwidth">{{ treestatus_update_trees_form.reason_category() }}</div> + </div> + </div> + <div class="field"> + <label class="label">{{ treestatus_update_trees_form.reason.label }}</label> + <div class="control">{{ treestatus_update_trees_form.reason(class="input") }}</div> + </div> + <div class="field"> + <label class="checkbox label"> + {{ treestatus_update_trees_form.remember.label }} + </label> + <div class="control"> + {{ treestatus_update_trees_form.remember(input="class", checked=True) }} + Remember this change to undo later. + </div> + </div> + <div class="field"> + <label class="label">{{ treestatus_update_trees_form.message_of_the_day.label }}</label> + <div class="control">{{ treestatus_update_trees_form.message_of_the_day(class="input") }}</div> + <p class="help">Optional</p> + </div> + </section> + <footer class="modal-card-foot"> + <button class="button is-primary" title="Update Tree">Update trees</button> + </footer> + </div> + <button class="modal-close is-large update-trees-modal-close" aria-label="close" type="button"></button> +</div> +{% endblock %} diff --git a/landoui/treestatus.py b/landoui/treestatus.py new file mode 100644 index 00000000..553fc1a1 --- /dev/null +++ b/landoui/treestatus.py @@ -0,0 +1,359 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# 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 logging + +from flask import ( + Blueprint, + flash, + redirect, + render_template, + request, + url_for, +) + +from landoui.helpers import ( + is_user_authenticated, + set_last_local_referrer, +) +from landoui.landoapi import ( + TreestatusAPI, + LandoAPIError, +) +from landoui.forms import ( + ReasonCategory, + TreeCategory, + TreeStatusLogUpdateForm, + TreeStatusNewTreeForm, + TreeStatusRecentChangesForm, + TreeStatusUpdateTreesForm, + build_update_json_body, +) + +logger = logging.getLogger(__name__) + +treestatus_blueprint = Blueprint("treestatus", __name__) +treestatus_blueprint.before_request(set_last_local_referrer) + + +def get_recent_changes_stack(api: TreestatusAPI) -> list[dict]: + """Retrieve recent changes stack data with error handling.""" + try: + response = api.request( + "GET", + "stack", + ) + except LandoAPIError as exc: + if not exc.detail: + raise exc + + flash(f"Could not retrieve recent changes stack: {exc.detail}.", "error") + return [] + + return response["result"] + + +def build_recent_changes_stack( + recent_changes_data: list[dict], +) -> list[tuple[TreeStatusRecentChangesForm, dict]]: + """Build the recent changes stack object.""" + return [ + ( + TreeStatusRecentChangesForm( + id=change["id"], + reason=change["reason"], + reason_category=( + change["trees"][0]["last_state"]["current_tags"][0] + if change["trees"][0]["last_state"]["current_tags"] + else ReasonCategory.NO_CATEGORY.value + ), + ), + change, + ) + for change in recent_changes_data + ] + + +@treestatus_blueprint.route("/treestatus/", methods=["GET", "POST"]) +def treestatus(): + """Display the status of all the current trees. + + This view is the main landing page for Treestatus. The view is a list of all trees + and their current statuses. The view of all trees is a form where each tree can be + selected, and clicking "Update trees" opens a modal which presents the tree updating + form. + """ + api = TreestatusAPI.from_environment() + + treestatus_update_trees_form = TreeStatusUpdateTreesForm() + if treestatus_update_trees_form.validate_on_submit(): + # Submit the form. + return update_treestatus(api, treestatus_update_trees_form) + + if ( + treestatus_update_trees_form.is_submitted() + and not treestatus_update_trees_form.validate() + ): + # Flash form submission errors. + for errors in treestatus_update_trees_form.errors.values(): + for error in errors: + flash(error, "warning") + + trees_response = api.request("GET", "trees") + trees = trees_response.get("result") + + if not treestatus_update_trees_form.trees.entries: + ordered_tree_choices = sorted(trees.values(), key=TreeCategory.sort_trees) + for tree in ordered_tree_choices: + treestatus_update_trees_form.trees.append_entry(tree["tree"]) + + recent_changes_data = get_recent_changes_stack(api) + recent_changes_stack = build_recent_changes_stack(recent_changes_data) + + return render_template( + "treestatus/trees.html", + recent_changes_stack=recent_changes_stack, + trees=trees, + treestatus_update_trees_form=treestatus_update_trees_form, + ) + + +def update_treestatus(api: TreestatusAPI, update_trees_form: TreeStatusUpdateTreesForm): + """Handler for the tree status updating form. + + This function handles form submission for the status updating form. Validate + the form submission and submit a request to LandoAPI, redirecting to the main + Treestatus page on success. Display an error message and return to the form if + the status updating rules were broken or the API returned an error. + """ + if not is_user_authenticated(): + flash("Authentication is required to update tree statuses.") + return redirect(request.referrer, code=401) + + logger.info(f"Requesting tree status update.") + + try: + api.request( + "PATCH", + "trees", + require_auth0=True, + json=update_trees_form.to_submitted_json(), + ) + except LandoAPIError as exc: + logger.info("Request to update trees status failed.") + if not exc.detail: + raise exc + + flash(f"Could not update trees: {exc.detail}. Please try again later.", "error") + return redirect(request.referrer), 303 + + # Redirect to the main Treestatus page. + logger.info("Tree statuses updated successfully.") + flash("Tree statuses updated successfully.") + return redirect(url_for("treestatus.treestatus")) + + +@treestatus_blueprint.route("/treestatus/new_tree/", methods=["GET", "POST"]) +def new_tree(): + """View for the new tree form.""" + api = TreestatusAPI.from_environment() + treestatus_new_tree_form = TreeStatusNewTreeForm() + + if treestatus_new_tree_form.validate_on_submit(): + return new_tree_handler(api, treestatus_new_tree_form) + + recent_changes_data = get_recent_changes_stack(api) + recent_changes_stack = build_recent_changes_stack(recent_changes_data) + + return render_template( + "treestatus/new_tree.html", + treestatus_new_tree_form=treestatus_new_tree_form, + recent_changes_stack=recent_changes_stack, + ) + + +def new_tree_handler(api: TreestatusAPI, form: TreeStatusNewTreeForm): + """Handler for the new tree form.""" + if not is_user_authenticated(): + flash("Authentication is required to create new trees.") + return redirect(request.referrer, code=401) + + # Retrieve data from the form. + tree = form.tree.data + tree_category = form.category.data + + logger.info(f"Requesting new tree {tree}.") + + try: + api.request( + "PUT", + f"trees/{tree}", + require_auth0=True, + json={ + "tree": tree, + "category": tree_category, + # Trees are open on creation. + "status": "open", + "reason": "", + "message_of_the_day": "", + }, + ) + except LandoAPIError as exc: + logger.info(f"Could not create new tree {tree}.") + + if not exc.detail: + raise exc + + flash( + f"Could not create new tree: {exc.detail}. Please try again later.", "error" + ) + return redirect(request.referrer), 303 + + logger.info(f"New tree {tree} created successfully.") + flash(f"New tree {tree} created successfully.") + return redirect(url_for("treestatus.treestatus")) + + +@treestatus_blueprint.route("/treestatus/<tree>/", methods=["GET"]) +def treestatus_tree(tree: str): + """Display the log of statuses for an individual tree.""" + api = TreestatusAPI.from_environment() + + try: + logs_response = api.request("GET", f"trees/{tree}/logs") + except LandoAPIError as exc: + if not exc.detail or not exc.status_code: + raise + + # Avoid displaying an error for a 404. + if exc.status_code != 404: + error = f"Error received from LandoAPI: {exc.detail}" + logger.info(error) + flash(error, "error") + + return redirect(request.referrer, code=exc.status_code) + + logs = logs_response.get("result") + if not logs: + logger.info(f"Could not retrieve logs for tree {tree}.") + flash( + f"Could not retrieve status logs for {tree} from Lando, try again later.", + "error", + ) + return redirect(request.referrer) + + current_log = logs[0] + + logs = [ + ( + TreeStatusLogUpdateForm( + reason=log["reason"], + reason_category=log["tags"][0] + if log["tags"] + else ReasonCategory.NO_CATEGORY.value, + ), + log, + ) + for log in logs + ] + + recent_changes_data = get_recent_changes_stack(api) + recent_changes_stack = build_recent_changes_stack(recent_changes_data) + + return render_template( + "treestatus/log.html", + current_log=current_log, + logs=logs, + recent_changes_stack=recent_changes_stack, + tree=tree, + ) + + +@treestatus_blueprint.route("/treestatus/stack/<int:id>", methods=["POST"]) +def update_change(id: int): + """Handler for stack updates. + + This function handles form submissions for updates to entries in the recent changes + stack. This includes pressing the "restore" or "discard" buttons, as well as updates + to the reason and reason category after pressing "edit" and "update". + """ + if not is_user_authenticated(): + flash("Authentication is required to update stack entries.") + return redirect(request.referrer, code=401) + + api = TreestatusAPI.from_environment() + recent_changes_form = TreeStatusRecentChangesForm() + + action = recent_changes_form.to_action() + + logger.info(f"Requesting stack update for stack id {id}.") + + try: + api.request( + action.method, + f"stack/{id}", + require_auth0=True, + **action.request_args, + ) + except LandoAPIError as exc: + logger.info(f"Stack entry {id} failed to update.") + + if not exc.detail: + raise exc + + flash( + f"Could not modify recent change: {exc.detail}. Please try again later.", + "error", + ) + return redirect(request.referrer), 303 + + logger.info(f"Stack entry {id} updated.") + flash(action.message) + return redirect(request.referrer) + + +@treestatus_blueprint.route("/treestatus/log/<int:id>", methods=["POST"]) +def update_log(id: int): + """Handler for log updates. + + This function handles form submissions for updates to individual log entries + in the per-tree log view. + """ + if not is_user_authenticated(): + flash("Authentication is required to update log entries.") + return redirect(request.referrer, code=401) + + api = TreestatusAPI.from_environment() + + log_update_form = TreeStatusLogUpdateForm() + + reason = log_update_form.reason.data + reason_category = log_update_form.reason_category.data + + json_body = build_update_json_body(reason, reason_category) + + logger.info(f"Requesting log update for log id {id}.") + + try: + api.request( + "PATCH", + f"log/{id}", + require_auth0=True, + json=json_body, + ) + except LandoAPIError as exc: + logger.info(f"Log entry {id} failed to update.") + + if not exc.detail: + raise exc + + flash( + f"Could not modify log entry: {exc.detail}. Please try again later.", + "error", + ) + return redirect(request.referrer), 303 + + logger.info(f"Log entry {id} updated.") + flash("Log entry updated.") + return redirect(request.referrer) diff --git a/landoui/wsgi.py b/landoui/wsgi.py index d3ebcf5e..4995b80f 100644 --- a/landoui/wsgi.py +++ b/landoui/wsgi.py @@ -18,5 +18,6 @@ use_https=str2bool(os.getenv("USE_HTTPS", 1)), enable_asset_pipeline=True, lando_api_url=os.getenv("LANDO_API_URL"), + treestatus_url=os.getenv("TREESTATUS_URL"), debug=str2bool(os.getenv("DEBUG", 0)), ) diff --git a/tests/conftest.py b/tests/conftest.py index 5ed04241..7c15ad29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,7 @@ def docker_env_vars(monkeypatch): monkeypatch.setenv("SESSION_COOKIE_SECURE", "0") monkeypatch.setenv("USE_HTTPS", "0") monkeypatch.setenv("LANDO_API_URL", "http://lando-api.test:8888") + monkeypatch.setenv("TREESTATUS_URL", "http://treestatus.test:8888") monkeypatch.setenv("SENTRY_DSN", "") monkeypatch.setenv("LOG_LEVEL", "DEBUG") @@ -42,7 +43,13 @@ def api_url(): @pytest.fixture -def app(versionfile, docker_env_vars, api_url): +def treestatus_url(): + """A string holding the Treestatus API base URL. Useful for request mocking.""" + return "http://treestatus.test" + + +@pytest.fixture +def app(versionfile, docker_env_vars, api_url, treestatus_url): app = create_app( version_path=versionfile.strpath, secret_key=str(binascii.b2a_hex(os.urandom(15))), @@ -52,6 +59,7 @@ def app(versionfile, docker_env_vars, api_url): use_https=False, enable_asset_pipeline=False, lando_api_url=api_url, + treestatus_url=treestatus_url, debug=True, ) diff --git a/tests/test_treestatus.py b/tests/test_treestatus.py new file mode 100644 index 00000000..fd0bb8d1 --- /dev/null +++ b/tests/test_treestatus.py @@ -0,0 +1,233 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# 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 pytest + +from wtforms.validators import ValidationError +from landoui.forms import ( + TreeStatusRecentChangesForm, + TreeStatusUpdateTreesForm, +) +from landoui.treestatus import ( + build_recent_changes_stack, + build_update_json_body, +) + + +def test_build_recent_changes_stack(app): + recent_changes_data = [ + { + "id": None, + "reason": "reason 2", + "trees": [{"last_state": {"current_tags": ["category 1"]}}], + "who": "user2", + "when": "now", + }, + { + "id": 2, + "reason": "reason 2", + "trees": [{"last_state": {"current_tags": ["category 2"]}}], + "who": "user2", + "when": "now", + }, + { + "id": 3, + "reason": "reason 3", + "trees": [{"last_state": {"current_tags": []}}], + "who": "user3", + "when": "now", + }, + ] + + recent_changes_stack = build_recent_changes_stack(recent_changes_data) + + for form, data in recent_changes_stack: + assert form.id.data == data["id"] + assert form.reason.data == data["reason"] + + if form.id.data == 3: + assert ( + form.reason_category.data == "" + ), "Empty tags should set field to an empty string." + else: + assert ( + form.reason_category.data + == data["trees"][0]["last_state"]["current_tags"][0] + ) + + +def test_build_update_json_body(): + assert build_update_json_body(None, None) == { + "reason": None, + } + + assert build_update_json_body("blah", None) == { + "reason": "blah", + } + + assert build_update_json_body("blah", "") == { + "reason": "blah", + }, "`tags` should not be set for empty reason category." + + assert build_update_json_body("blah", "asdf") == { + "reason": "blah", + }, "`tags` should not be set for invalid reason category." + + assert build_update_json_body("blah", "backlog") == { + "reason": "blah", + "tags": ["backlog"], + }, "`tags` should be set for valid reason category." + + +def test_update_form_validate_trees(app): + form = TreeStatusUpdateTreesForm() + + # At least one tree is required. + with pytest.raises(ValidationError): + form.validate_trees(form.trees) + + form.trees.append_entry("autoland") + assert ( + form.validate_trees(form.trees) is None + ), "Form with a tree entry should be valid." + + +def test_update_form_validate_reason(app): + form = TreeStatusUpdateTreesForm( + status="open", + ) + try: + form.validate_reason(form.reason) + except ValidationError as exc: + assert False, f"No validation required for `open` status: {exc}." + + form = TreeStatusUpdateTreesForm( + status="approval required", + ) + try: + form.validate_reason(form.reason) + except ValidationError as exc: + assert False, f"No validation required for `approval required` status: {exc}." + + # `closed` status requires a reason. + form = TreeStatusUpdateTreesForm( + status="closed", + ) + with pytest.raises(ValidationError): + form.validate_reason(form.reason) + + form = TreeStatusUpdateTreesForm( + status="closed", + reason="some reason", + ) + try: + form.validate_reason(form.reason) + except ValidationError as exc: + assert False, f"`closed` status with a reason should be valid: {exc}" + + +def test_update_form_validate_reason_category(app): + form = TreeStatusUpdateTreesForm( + status="open", + ) + try: + form.validate_reason_category(form.reason_category) + except ValidationError as exc: + assert False, f"No validation required for `open` status: {exc}." + + form = TreeStatusUpdateTreesForm( + status="approval required", + ) + try: + form.validate_reason_category(form.reason_category) + except ValidationError as exc: + assert False, f"No validation required for `approval required` status: {exc}." + + # `closed` status requires a reason category. + form = TreeStatusUpdateTreesForm( + status="closed", + ) + with pytest.raises(ValidationError): + form.validate_reason_category(form.reason_category) + + # `closed` status requires a non-empty reason category. + form = TreeStatusUpdateTreesForm( + status="closed", + reason_category="", + ) + with pytest.raises(ValidationError): + form.validate_reason_category(form.reason_category) + + # `closed` status requires a valid reason category. + form = TreeStatusUpdateTreesForm( + status="closed", + reason_category="blah", + ) + with pytest.raises(ValidationError): + form.validate_reason_category(form.reason_category) + + form = TreeStatusUpdateTreesForm( + status="closed", + reason_category="backlog", + ) + try: + form.validate_reason_category(form.reason_category) + except ValidationError as exc: + assert False, f"`closed` status with valid reason category is valid: {exc}." + + +def test_update_form_to_submitted_json(app): + form = TreeStatusUpdateTreesForm( + status="open", + reason="reason", + message_of_the_day="", + remember=True, + reason_category="backlog", + ) + + form.trees.append_entry("autoland") + form.trees.append_entry("mozilla-central") + + assert form.to_submitted_json() == { + "status": "open", + "reason": "reason", + "message_of_the_day": "", + "remember": True, + "tags": ["backlog"], + "trees": ["autoland", "mozilla-central"], + }, "JSON format should match expected." + + +def test_recent_changes_action(app): + form = TreeStatusRecentChangesForm( + reason="reason", + category="backlog", + ) + + form.update.data = True + action = form.to_action() + + assert action.method == "PATCH", "Updates should use HTTP `PATCH`." + assert "json" in action.request_args, "Updates should send content in JSON body." + assert action.message == "Status change updated." + + form.update.data = False + form.restore.data = True + action = form.to_action() + + assert action.method == "DELETE", "Restores should use HTTP `DELETE`." + assert ( + "params" in action.request_args + ), "Restores should send content in query string." + assert action.message == "Status change restored." + + form.restore.data = False + form.discard.data = True + action = form.to_action() + + assert action.method == "DELETE", "Discards should use HTTP `DELETE`." + assert ( + "params" in action.request_args + ), "Discards should send content in query string." + assert action.message == "Status change discarded."