From 502c13031d5e74f690bf95e3314476873cf57fc6 Mon Sep 17 00:00:00 2001 From: Dustin Lactin <dustin.lactin@gmail.com> Date: Thu, 14 Dec 2023 09:12:07 -0700 Subject: [PATCH 01/12] fix(circleci): Using CIRCLE_TAG in develop and stage image tags to move to immutable tags for deployment (#192) --- .circleci/config.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 55ebd7ea..5e91fedb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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_TAG}" + docker push "${DOCKERHUB_REPO}:develop-${CIRCLE_TAG}" 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_TAG}" + docker push "${DOCKERHUB_REPO}:staging-${CIRCLE_TAG}" fi fi From b470018b18d96f52c0860283880592d630c0bd2d Mon Sep 17 00:00:00 2001 From: Dustin Lactin <dlactin@mozilla.com> Date: Thu, 21 Dec 2023 10:33:52 -0700 Subject: [PATCH 02/12] circleci: Using SHA1 in dev/stage image name instead of tag (#193) --- .circleci/config.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5e91fedb..0e1ac5c5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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-${CIRCLE_TAG}" - docker push "${DOCKERHUB_REPO}:develop-${CIRCLE_TAG}" + 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-${CIRCLE_TAG}" - docker push "${DOCKERHUB_REPO}:staging-${CIRCLE_TAG}" + docker tag "mozilla/landoui" "${DOCKERHUB_REPO}:staging-${CIRCLE_SHA1}" + docker push "${DOCKERHUB_REPO}:staging-${CIRCLE_SHA1}" fi fi From fd16176d3fd5a81c1074a4d0a6ee6145f2e10d51 Mon Sep 17 00:00:00 2001 From: Connor Sheehan <cosheehan@mozilla.com> Date: Mon, 8 Jan 2024 12:24:16 -0500 Subject: [PATCH 03/12] stack: move the uplift request form into a separate modal (Bug 1801959) (#194) Create a new modal div that contains the uplift request form. Add a new button for requesting uplifts next to the "Preview Landing" button. Remove the code that allows pressing the "Preview Landing" button even when the stack is not landable since we no longer have the uplift button behind that modal. --- landoui/assets_src/js/components/Stack.js | 8 ++ landoui/templates/stack/stack.html | 111 +++++++++++++++------- 2 files changed, 86 insertions(+), 33 deletions(-) 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/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 %} From b1be0a83e3b1d9555df7099c7834dcb724433d1d Mon Sep 17 00:00:00 2001 From: Connor Sheehan <cosheehan@mozilla.com> Date: Fri, 26 Apr 2024 12:33:29 -0700 Subject: [PATCH 04/12] landing-preview: add a shim to display `blockers` dryrun info if available (Bug 1888188) (#198) As part of the checks refactor we will eventually support displaying multiple blockers in the front end. Add a shim so Lando-UI can handle output from before and after the refactor PR in LandoAPI is landed and deployed. --- landoui/templates/stack/partials/landing-preview.html | 5 +++-- landoui/templates/stack/partials/revision-status.html | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) 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..4294c38c 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 From dbc5f2115ee42b66c35c8cd6d151599f89bef2d5 Mon Sep 17 00:00:00 2001 From: Connor Sheehan <cosheehan@mozilla.com> Date: Mon, 6 May 2024 11:26:34 -0700 Subject: [PATCH 05/12] treestatus: implement Treestatus UI (Bug 1817473) (#186) Implement a Treestatus UI in Lando. All endpoints in the new interface are namespaced under `/treestatus`. The main components are as follows. The main Treestatus page displays all trees and their current statuses. When authenticated, each tree can be selected for updating via a checkbox, or a "select all trees" button is available for CI-wide tree closures. The tree selections are forwarded to the update form when "Update trees" is selected. The "update trees" view implements a form for updating the status of trees. Trees can be set to open, closed and "approval required". When the status is being set to a non-open state, a reason and reason category must be specified. The form presents a "remember this change" option which can be used to easily revert the change at a later time. For example if all trees are closed, the trees can be mass re-opened easily. The individual log view of a tree presents the most recent status updates for each individual tree. When authenticated, users can edit the tree status reason or category in case of a typo or error during submission by clicking the "edit" button, which toggles the display to a form for updating. The recent changes view is a header that appears for authenticated users on all pages. It presents all status updates that have been "remembered" during update. Users can edit the reason and reason category using an "edit" button that toggles the display to a form. The "restore" button can be used to revert the status change. The "discard" button can be used to throw away the status change if it is no longer necessary to remember the change. The Treestatus UI uses message flashing and the existing Lando-UI notifications component to present errors and action verification to the user. --- landoui/app.py | 4 + landoui/assets_app.py | 1 + .../assets_src/css/pages/TreestatusPage.scss | 27 ++ .../assets_src/js/components/Treestatus.js | 80 +++++ landoui/assets_src/js/main.js | 2 + landoui/dev_app.py | 1 + landoui/forms.py | 272 ++++++++++++++- landoui/landoapi.py | 38 +- landoui/template_helpers.py | 42 ++- landoui/templates/partials/navbar.html | 6 + landoui/templates/treestatus/log.html | 69 ++++ landoui/templates/treestatus/new_tree.html | 35 ++ .../templates/treestatus/recent_changes.html | 65 ++++ landoui/templates/treestatus/trees.html | 79 +++++ .../templates/treestatus/update_trees.html | 57 +++ landoui/treestatus.py | 329 ++++++++++++++++++ landoui/wsgi.py | 1 + tests/conftest.py | 10 +- tests/test_treestatus.py | 233 +++++++++++++ 19 files changed, 1340 insertions(+), 11 deletions(-) create mode 100644 landoui/assets_src/css/pages/TreestatusPage.scss create mode 100644 landoui/assets_src/js/components/Treestatus.js create mode 100644 landoui/templates/treestatus/log.html create mode 100644 landoui/templates/treestatus/new_tree.html create mode 100644 landoui/templates/treestatus/recent_changes.html create mode 100644 landoui/templates/treestatus/trees.html create mode 100644 landoui/templates/treestatus/update_trees.html create mode 100644 landoui/treestatus.py create mode 100644 tests/test_treestatus.py 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/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..9da522f4 100644 --- a/landoui/template_helpers.py +++ b/landoui/template_helpers.py @@ -12,7 +12,11 @@ from typing import Optional from flask import Blueprint, current_app, escape -from landoui.forms import UserSettingsForm +from landoui.forms import ( + ReasonCategory, + TreeCategory, + UserSettingsForm, +) from landoui import helpers @@ -92,6 +96,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 +144,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 +355,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/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..2e6845da --- /dev/null +++ b/landoui/templates/treestatus/trees.html @@ -0,0 +1,79 @@ +{# + 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> + + {% include "treestatus/recent_changes.html" %} + + <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_user_authenticated() %} + <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_user_authenticated() %} + <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..09af62b3 --- /dev/null +++ b/landoui/treestatus.py @@ -0,0 +1,329 @@ +# 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 ( + 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. + """ + 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.exception("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.""" + # 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.exception(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() + + logs_response = api.request("GET", f"trees/{tree}/logs") + logs = logs_response.get("result") + if not logs: + logger.error(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". + """ + 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.exception(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. + """ + 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.exception(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." From e590c40a305ac5787d348655d4c24d6cfd6439ea Mon Sep 17 00:00:00 2001 From: Connor Sheehan <cosheehan@mozilla.com> Date: Tue, 7 May 2024 09:16:55 -0700 Subject: [PATCH 06/12] circleci: update base image and use `compose` plugin (Bug 1895531) (#200) --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0e1ac5c5..0fc7611c 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 From ee9ec2aec852d4e14c5c4606f516d7b66107dec7 Mon Sep 17 00:00:00 2001 From: Connor Sheehan <cosheehan@mozilla.com> Date: Tue, 7 May 2024 09:23:40 -0700 Subject: [PATCH 07/12] revision-status: use `blocked_reason` variable instead of pre-refactor key (Bug 1888188) (#199) In the original revision I added a `blocked_reason` variable to avoid repeated code, however I forgot to update the template to actually use the variable. --- landoui/templates/stack/partials/revision-status.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landoui/templates/stack/partials/revision-status.html b/landoui/templates/stack/partials/revision-status.html index 4294c38c..8d09f975 100644 --- a/landoui/templates/stack/partials/revision-status.html +++ b/landoui/templates/stack/partials/revision-status.html @@ -14,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 %} From b17885d39cbc2333e9f96d9b15f8d24b368b0b72 Mon Sep 17 00:00:00 2001 From: Connor Sheehan <cosheehan@mozilla.com> Date: Tue, 14 May 2024 08:19:18 -0700 Subject: [PATCH 08/12] treestatus: check for user authentication before `require_auth0` API calls (Bug 1896642) (#203) In other parts of Lando, before making an API call that uses the `require_auth0` kwarg, we do an explicit check that the user is authenticated and handle unauthenticated requests appropriately. This was overlooked in implementing the Treestatus UI and is resulting in errors in Sentry since we instead only discover the missing credentials when making the request, resulting in an unhandled exception. Add an explicit check to each handler which makes an `auth0_required` API call. --- landoui/treestatus.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/landoui/treestatus.py b/landoui/treestatus.py index 09af62b3..3c87af16 100644 --- a/landoui/treestatus.py +++ b/landoui/treestatus.py @@ -14,6 +14,7 @@ ) from landoui.helpers import ( + is_user_authenticated, set_last_local_referrer, ) from landoui.landoapi import ( @@ -126,6 +127,10 @@ def update_treestatus(api: TreestatusAPI, update_trees_form: TreeStatusUpdateTre 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: @@ -170,6 +175,10 @@ def new_tree(): 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 @@ -256,6 +265,10 @@ def update_change(id: int): 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() @@ -294,6 +307,10 @@ def update_log(id: int): 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() From af9443b1f5776600a781b14b9c656a6961216e4f Mon Sep 17 00:00:00 2001 From: Connor Sheehan <cosheehan@mozilla.com> Date: Tue, 14 May 2024 09:55:13 -0700 Subject: [PATCH 09/12] treestatus: handle 404 from tree logs reponse properly (Bug 1896642) (#202) When calling out to the tree logs endpoint, we do not properly handle non-2XX reponse codes within LandoUI, as those throw a `LandoAPIError` which must be handled. Add a `try/except` block around the error to properly handle tree names that return 404 from LandoAPI and other errors. --- landoui/treestatus.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/landoui/treestatus.py b/landoui/treestatus.py index 3c87af16..5e38904d 100644 --- a/landoui/treestatus.py +++ b/landoui/treestatus.py @@ -220,7 +220,17 @@ def treestatus_tree(tree: str): """Display the log of statuses for an individual tree.""" api = TreestatusAPI.from_environment() - logs_response = api.request("GET", f"trees/{tree}/logs") + try: + logs_response = api.request("GET", f"trees/{tree}/logs") + except LandoAPIError as exc: + if not exc.detail or not exc.status_code: + raise + + error = f"Error received from LandoAPI: {exc.detail}" + logger.error(error) + flash(error, "error") + return redirect(request.referrer, code=exc.status_code) + logs = logs_response.get("result") if not logs: logger.error(f"Could not retrieve logs for tree {tree}.") From be9d1e3bd2e809ca5ffee3e8936f831f534c917a Mon Sep 17 00:00:00 2001 From: Connor Sheehan <cosheehan@mozilla.com> Date: Wed, 15 May 2024 07:01:55 -0700 Subject: [PATCH 10/12] circleci: change deploy step to run (Bug 1895570) (#201) --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0fc7611c..bf756707 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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" From 80abbcbefc5df8d4b69843078d02f5c1d3158685 Mon Sep 17 00:00:00 2001 From: Connor Sheehan <cosheehan@mozilla.com> Date: Thu, 16 May 2024 06:34:22 -0700 Subject: [PATCH 11/12] treestatus: convert logging of Treestatus API errors to `info` level (Bug 1897044) (#204) The `error` and `exception` level logging calls cause Sentry to treat expected states as new issues. Lower the level to `info` to quiet down Sentry. Issues experienced by the Treestatus API will still show up in the LandoAPI logs, and at the `info` level we can still debug which code paths are taken by various requests. --- landoui/treestatus.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/landoui/treestatus.py b/landoui/treestatus.py index 5e38904d..553fc1a1 100644 --- a/landoui/treestatus.py +++ b/landoui/treestatus.py @@ -141,7 +141,7 @@ def update_treestatus(api: TreestatusAPI, update_trees_form: TreeStatusUpdateTre json=update_trees_form.to_submitted_json(), ) except LandoAPIError as exc: - logger.exception("Request to update trees status failed.") + logger.info("Request to update trees status failed.") if not exc.detail: raise exc @@ -200,7 +200,7 @@ def new_tree_handler(api: TreestatusAPI, form: TreeStatusNewTreeForm): }, ) except LandoAPIError as exc: - logger.exception(f"Could not create new tree {tree}.") + logger.info(f"Could not create new tree {tree}.") if not exc.detail: raise exc @@ -226,14 +226,17 @@ def treestatus_tree(tree: str): if not exc.detail or not exc.status_code: raise - error = f"Error received from LandoAPI: {exc.detail}" - logger.error(error) - flash(error, "error") + # 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.error(f"Could not retrieve logs for tree {tree}.") + 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", @@ -294,7 +297,7 @@ def update_change(id: int): **action.request_args, ) except LandoAPIError as exc: - logger.exception(f"Stack entry {id} failed to update.") + logger.info(f"Stack entry {id} failed to update.") if not exc.detail: raise exc @@ -340,7 +343,7 @@ def update_log(id: int): json=json_body, ) except LandoAPIError as exc: - logger.exception(f"Log entry {id} failed to update.") + logger.info(f"Log entry {id} failed to update.") if not exc.detail: raise exc From 44428c6b3b97c0129d3a2998b84f96ebaf3517d4 Mon Sep 17 00:00:00 2001 From: Connor Sheehan <cosheehan@mozilla.com> Date: Fri, 19 Jul 2024 07:23:04 -0700 Subject: [PATCH 12/12] templates: hide Treestatus editing capabilities from unprivileged users (Bug 1901484) (#205) Add a new `is_treestatus_user` template helper which checks for membership in the Treestatus Mozillians groups. Switch to using this new function to determine if Treestatus editing elements of the UI should be shown. --- landoui/template_helpers.py | 21 ++++++++++++++++++++- landoui/templates/treestatus/trees.html | 6 ++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/landoui/template_helpers.py b/landoui/template_helpers.py index 9da522f4..c9443e18 100644 --- a/landoui/template_helpers.py +++ b/landoui/template_helpers.py @@ -11,7 +11,7 @@ from typing import Optional -from flask import Blueprint, current_app, escape +from flask import Blueprint, current_app, escape, session from landoui.forms import ( ReasonCategory, TreeCategory, @@ -32,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 diff --git a/landoui/templates/treestatus/trees.html b/landoui/templates/treestatus/trees.html index 2e6845da..30439f19 100644 --- a/landoui/templates/treestatus/trees.html +++ b/landoui/templates/treestatus/trees.html @@ -12,7 +12,9 @@ <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> {# @@ -22,7 +24,7 @@ <h1>Trees</h1> <form method="post"> {{ treestatus_update_trees_form.csrf_token }} - {% if is_user_authenticated() %} + {% 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> @@ -47,7 +49,7 @@ <h4 class="subtitle is-4 tree-category-header">{{ ns.current_category | tree_cat <div class="select-trees-box box"> <div class="columns"> - {% if is_user_authenticated() %} + {% 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>