From ec4870c7c3cb56d4b4f2586e0f9bf757df67e4fd Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Mon, 9 Sep 2024 10:52:37 +0300 Subject: [PATCH 01/15] Added jira endpoint --- .env | 5 + webapp/__init__.py | 4 + webapp/app.py | 75 +++++++++---- webapp/jira.py | 262 +++++++++++++++++++++++++++++++++++++++++++++ webapp/schemas.py | 31 ++++++ webapp/settings.py | 5 + webapp/utils.py | 25 +++++ 7 files changed, 385 insertions(+), 22 deletions(-) create mode 100644 webapp/jira.py create mode 100644 webapp/schemas.py create mode 100644 webapp/utils.py diff --git a/.env b/.env index 56cc4c13..8ec94291 100644 --- a/.env +++ b/.env @@ -7,3 +7,8 @@ REPO_ORG=https://github.com/canonical SQLALCHEMY_DATABASE_URI=postgresql://postgres:postgres@localhost:5432/postgres TASK_DELAY=30 DIRECTORY_API_TOKEN=token +JIRA_EMAIL=email@example.com +JIRA_TOKEN=token +JIRA_URL=https://warthogs.atlassian.net +JIRA_LABELS=sites_BAU +JIRA_COPY_UPDATES_EPIC=KAN-1 diff --git a/webapp/__init__.py b/webapp/__init__.py index b1e7bc2a..420b84bd 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -2,6 +2,7 @@ from webapp.cache import init_cache from webapp.context import base_context +from webapp.jira import init_jira from webapp.models import init_db from webapp.sso import init_sso from webapp.tasks import init_tasks @@ -26,4 +27,7 @@ def create_app(): # Initialize tasks init_tasks(app) + # Initialize JIRA + init_jira(app) + return app diff --git a/webapp/app.py b/webapp/app.py index 6c7c91ef..0ec3361f 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -1,15 +1,19 @@ from os import environ +import requests from flask import jsonify, render_template, request +from flask_pydantic import validate from webapp import create_app +from webapp.helper import get_or_create_user_id +from webapp.models import JiraTask, Reviewer, Webpage, db, get_or_create +from webapp.schemas import ( + ChangesRequestModel, + ResponseModel, +) from webapp.site_repository import SiteRepository from webapp.sso import login_required from webapp.tasks import LOCKS -from webapp.models import get_or_create, db, Reviewer, Webpage -from webapp.helper import get_or_create_user_id - -import requests app = create_app() @@ -43,7 +47,7 @@ def index(path): return render_template("index.html") -@app.route('/get-users/', methods=['GET']) +@app.route("/get-users/", methods=["GET"]) @login_required def get_users(username: str): query = """ @@ -59,27 +63,28 @@ def get_users(username: str): } """ - headers = { - "Authorization": "token " + environ.get("DIRECTORY_API_TOKEN") - } + headers = {"Authorization": "token " + environ.get("DIRECTORY_API_TOKEN")} # Currently directory-api only supports strict comparison of field values, # so we have to send two requests instead of one for first and last names response = requests.post( - "https://directory.wpe.internal/graphql/", json={ - 'query': query, - 'variables': {'name': username.strip()}, - }, headers=headers, verify=False) - - if (response.status_code == 200): - users = response.json().get('data', {}).get( - 'employees', []) + "https://directory.wpe.internal/graphql/", + json={ + "query": query, + "variables": {"name": username.strip()}, + }, + headers=headers, + verify=False, + ) + + if response.status_code == 200: + users = response.json().get("data", {}).get("employees", []) return jsonify(list(users)) else: return jsonify({"error": "Failed to fetch users"}), 500 -@app.route('/set-reviewers', methods=['POST']) +@app.route("/set-reviewers", methods=["POST"]) @login_required def set_reviewers(): data = request.get_json() @@ -99,15 +104,12 @@ def set_reviewers(): # Create new reviewer rows for user_id in user_ids: - get_or_create(db.session, - Reviewer, - user_id=user_id, - webpage_id=webpage_id) + get_or_create(db.session, Reviewer, user_id=user_id, webpage_id=webpage_id) return jsonify({"message": "Successfully set reviewers"}), 200 -@app.route('/set-owner', methods=['POST']) +@app.route("/set-owner", methods=["POST"]) @login_required def set_owner(): data = request.get_json() @@ -123,3 +125,32 @@ def set_owner(): db.session.commit() return jsonify({"message": "Successfully set owner"}), 200 + + +@app.route("/request-changes", methods=["POST"]) +# @login_required +@validate() +def request_changes(body: ChangesRequestModel): + # Make a request to JIRA to create a task + jira = app.config["JIRA"] + try: + issue = jira.create_issue( + due_date=body.due_date, + reporter=body.reporter_id, + webpage_id=body.webpage_id, + issue_type=body.type, + description=body.description, + ) + except Exception as e: + return jsonify(ResponseModel(message=str(e)).model_dump(), 500) + + # Create jira task in the database + get_or_create( + db.session, + JiraTask, + jira_id=issue["id"], + webpage_id=body.webpage_id, + user_id=body.reporter_id, + status=issue["status"], + ) + return jsonify(ResponseModel().model_dump(), 200) diff --git a/webapp/jira.py b/webapp/jira.py new file mode 100644 index 00000000..3063c104 --- /dev/null +++ b/webapp/jira.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import json +from datetime import datetime +from typing import Literal, Optional + +import requests +from pydantic import BaseModel +from requests.auth import HTTPBasicAuth + +from webapp.models import User, Webpage + + +class Issue(BaseModel): + project: str + summary: str + description: str + priority: str + issuetype: str + + +class JIRATaskRequestModel(BaseModel): + project: Literal["Web & Design - ENG"] + due_date: datetime + reporter: str + components: str + labels: str + description: Optional[str] = None + + +class JIRATaskResponseModel(BaseModel): + id: int + age: int + name: str + nickname: Optional[str] = None + + +class Jira: + headers = {"Accept": "application/json", "Content-Type": "application/json"} + + def __init__( + self, url: str, email: str, token: str, labels: str, copy_updates_epic: str + ): + """ + Initialize the Jira object. + + Args: + url (str): The URL of the Jira instance. + email (str): The email address of the user. + token (str): The API token of the user. + labels (str): The labels to be applied to the created issues. + copy_updates_epic (str): The key of the epic to copy updates to. + """ + self.url = url + self.auth = HTTPBasicAuth(email, token) + self.labels = labels + self.copy_updates_epic = copy_updates_epic + + def __request__(self, method: str, url: str, data: dict = {}, params: dict = {}): + if data == {}: + data = None + response = requests.request( + method, + url, + data=json.dumps(data), + headers=self.headers, + auth=self.auth, + params=params, + ) + + if response.status_code != 200: + raise Exception( + "Failed to make a request to Jira. Status code:" + f" {response.status_code}. Response: {response.text}" + ) + + json.loads(response.text) + + def get_reporter_jira_id(self, user_id): + """ + Get the Jira ID of the user who reported the issue. + + Args: + user_id (int): The ID of the user who reported the issue. + + Returns: + str: The Jira ID of the user who reported the issue. + """ + # Try to get the user from the database + user = User.query.filter_by(id=user_id).first() + if user: + if user.jira_account_id: + return user.jira_account_id + # Otherwise get it from jira + jira_user = self.find_user(user.email) + if not jira_user: + raise ValueError(f"User with email {user.email} not found in Jira") + # Update the user in the database + # user.jira_account_id = jira_user["accountId"] + # db.session.commit() + return jira_user["accountId"] + + def find_user(self, query: str): + """ + Find a user based on a query. + + Args: + query (str): The query string to search for a user. + + Returns: + dict: A dictionary containing the user information. + """ + return self.__request__( + method="GET", + url=f"{self.url}/rest/api/3/user/search", + params={"query": f"{query}"}, + ) + + def create_subtask( + self, + summary: str, + issue_type: int, + description: str, + parent: str, + reporter: str, + due_date: datetime, + ): + """ + Creates a subtask in Jira. + + Args: + summary (str): The summary of the subtask. + issue_type (int): The ID of the issue type for the subtask. + description (str): The description of the subtask. + parent (str): The key of the parent issue. If None, the subtask will be + created without a parent. + reporter (str): The ID of the reporter of the subtask. + due_date (datetime): The due date of the subtask. + + Returns: + dict: The response from the Jira API containing information about the + created subtask. + """ + if not parent: + parent = None + else: + parent = {"key": self.copy_updates_epic} + + payload = { + "fields": { + "description": { + "content": [ + { + "content": [ + { + "text": description, + "type": "text", + } + ], + "type": "paragraph", + } + ], + "type": "doc", + "version": 1, + }, + "summary": summary, + "issuetype": {"id": issue_type}, + "labels": self.labels, + "reporter": {"id": reporter}, + "duedate": due_date.date.isoformat(), + "parent": parent, + "project": {"id": "10000"}, # Hardcoded for now + "components": [{"id": "10000"}], # Hardcoded for now + }, + "update": {}, + } + return self.__request__( + method="POST", url=f"{self.url}/rest/api/3/issue", data=payload + ) + + def create_issue( + self, + issue_type: int, + description: str, + reporter: str, + webpage_id: int, + due_date: datetime, + ): + """Creates a new issue in Jira. + + Args: + issue_type (int): The type of the issue. 0 for Epic, 1 or 2 for Task. + description (str): The description of the issue. + reporter (str): The ID of the reporter. + webpage_id (int): The ID of the webpage. + due_date (datetime): The due date of the issue. + + Returns: + dict: The response from the Jira API. + """ + # Get the webpage + webpage = Webpage.query.filter_by(id=webpage_id).first() + if not webpage: + raise Exception(f"Webpage with ID {webpage_id} not found") + + # Get the reporter ID + reporter = self.get_reporter_jira_id(reporter) + + # Determine the correct issue type + if issue_type == 0: + issue_type_id = "10002" # Epic + parent = None + summary = f"Copy update {webpage.name}" + # Create epic + epic = self.create_subtask( + summary=None, + issue_type=issue_type_id, + description=description, + parent=parent, + reporter=reporter, + due_date=due_date, + ) + # Create subtasks for this epic + for subtask_name in ["UX", "Visual", "Dev"]: + self.create_subtask( + summary=f"{subtask_name}-{summary}", + issue_type=issue_type_id, + description=description, + parent=epic["id"], + reporter=reporter, + due_date=due_date, + ) + elif issue_type == 1 or issue_type == 2: + issue_type_id = "10001" # Task + parent = {"key": self.copy_updates_epic} + + # Determine summary message + if issue_type == 0: + summary = f"Copy update {webpage.name}" + elif issue_type == 1: + summary = f"Page refresh for {webpage.name}" + elif issue_type == 2: + summary = f"New webpage for {webpage.name}" + + return self.create_subtask( + summary=summary, + issue_type=issue_type_id, + description=description, + parent=parent, + reporter=reporter, + due_date=due_date, + ) + + +def init_jira(app): + app.config["JIRA"] = Jira( + url=app.config["JIRA_URL"], + email=app.config["JIRA_EMAIL"], + token=app.config["JIRA_TOKEN"], + labels=app.config["JIRA_LABELS"].split(","), + copy_updates_epic=app.config["JIRA_COPY_UPDATES_EPIC"], + ) diff --git a/webapp/schemas.py b/webapp/schemas.py new file mode 100644 index 00000000..ee5267ef --- /dev/null +++ b/webapp/schemas.py @@ -0,0 +1,31 @@ +from functools import wraps + +from pydantic import BaseModel + + +def validate_input(model): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + # Create a pydantic basemodel instance with provided kwargs + # Throws a ValidationError if the input data is invalid + model(**kwargs) + # Call the original function with validated inputs + return func(*args, **kwargs) + + return wrapper + + return decorator + + +{ + "due_date": "2022-01-01", + "reporter_id": 1, + "webpage_id": 1, + "type": 0, + "description": "This is a description", +} + + +class ResponseModel(BaseModel): + message: str = "OK" diff --git a/webapp/settings.py b/webapp/settings.py index 225ff2d4..61b6562f 100644 --- a/webapp/settings.py +++ b/webapp/settings.py @@ -10,3 +10,8 @@ SQLALCHEMY_DATABASE_URI = environ.get( "SQLALCHEMY_DATABASE_URI", "sqlite:///project.db" ) +JIRA_EMAIL = environ.get("JIRA_EMAIL") +JIRA_TOKEN = environ.get("JIRA_TOKEN") +JIRA_URL = environ.get("JIRA_URL") +JIRA_LABELS = environ.get("JIRA_LABELS") +JIRA_COPY_UPDATES_EPIC = environ.get("JIRA_COPY_UPDATES_EPIC") \ No newline at end of file diff --git a/webapp/utils.py b/webapp/utils.py new file mode 100644 index 00000000..3aaa3355 --- /dev/null +++ b/webapp/utils.py @@ -0,0 +1,25 @@ +import inspect +from multiprocessing import Process, Queue +from typing import Callable + +from flask import Flask + + +def enqueue_task( + func: Callable, app: Flask, queue: Queue, task_locks: dict, *args, **kwargs +): + """ + Start the task in a separate process. + """ + # func must take app, task queue, and locks as arguments + frame = inspect.currentframe() + _, _, _, values = inspect.getargvalues(frame) + for i in ["app", "queue", "task_locks"]: + if i not in values: + raise ValueError( + "Function must take app, task queue, and locks as arguments" + ) + + Process( + target=func, args=(app, queue, task_locks, *args), kwargs=kwargs + ).start() From 78afc484b2ee75149bef4c598ae895fc6370cc9b Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Fri, 13 Sep 2024 11:34:37 +0300 Subject: [PATCH 02/15] Fixed bug with ambiguous parent --- .github/workflows/CI.yml | 5 +++ requirements.txt | 1 + webapp/app.py | 33 +++++----------- webapp/helper.py | 48 ++++++++++++++++++----- webapp/jira.py | 84 +++++++++++++++++++++++----------------- webapp/schemas.py | 14 ++++--- 6 files changed, 113 insertions(+), 72 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index e5b7564b..3e4f5ecc 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -159,6 +159,11 @@ jobs: -e GH_TOKEN=token \ -e REPO_ORG=https://github.com/canonical \ -e SQLALCHEMY_DATABASE_URI=postgresql://postgres:postgres@localhost:5432/postgres \ + -e JIRA_EMAIL=example@canonical.com + -e JIRA_TOKEN=jiratoken + -e JIRA_URL=https://example.atlassian.net + -e JIRA_LABELS=somelabel + -e JIRA_COPY_UPDATES_EPIC=WD-9999999 --network host \ websites-content-system & sleep 1 curl --head --fail --retry-delay 1 --retry 30 --retry-connrefused http://localhost diff --git a/requirements.txt b/requirements.txt index b2adb6db..8b560e25 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ multiprocess==0.70.16 psycopg2-binary==2.9.9 PyYAML==6.0.2 Flask-Migrate==4.0.7 +Flask-Pydantic==0.12.0 Flask-SQLAlchemy==3.1.1 talisker[gunicorn,gevent,flask,prometheus,raven]==0.21.3 valkey==6.0.0b1 diff --git a/webapp/app.py b/webapp/app.py index 0ec3361f..cb8d6a71 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -6,10 +6,9 @@ from webapp import create_app from webapp.helper import get_or_create_user_id -from webapp.models import JiraTask, Reviewer, Webpage, db, get_or_create +from webapp.models import Reviewer, Webpage, db, get_or_create from webapp.schemas import ( ChangesRequestModel, - ResponseModel, ) from webapp.site_repository import SiteRepository from webapp.sso import login_required @@ -104,7 +103,9 @@ def set_reviewers(): # Create new reviewer rows for user_id in user_ids: - get_or_create(db.session, Reviewer, user_id=user_id, webpage_id=webpage_id) + get_or_create( + db.session, Reviewer, user_id=user_id, webpage_id=webpage_id + ) return jsonify({"message": "Successfully set reviewers"}), 200 @@ -131,26 +132,12 @@ def set_owner(): # @login_required @validate() def request_changes(body: ChangesRequestModel): + from webapp.helper import create_jira_task + # Make a request to JIRA to create a task - jira = app.config["JIRA"] try: - issue = jira.create_issue( - due_date=body.due_date, - reporter=body.reporter_id, - webpage_id=body.webpage_id, - issue_type=body.type, - description=body.description, - ) + create_jira_task(app, body.model_dump()) except Exception as e: - return jsonify(ResponseModel(message=str(e)).model_dump(), 500) - - # Create jira task in the database - get_or_create( - db.session, - JiraTask, - jira_id=issue["id"], - webpage_id=body.webpage_id, - user_id=body.reporter_id, - status=issue["status"], - ) - return jsonify(ResponseModel().model_dump(), 200) + return jsonify(e), 500 + + return jsonify("Task created successfully"), 201 diff --git a/webapp/helper.py b/webapp/helper.py index 6d14cb79..b075ecca 100644 --- a/webapp/helper.py +++ b/webapp/helper.py @@ -1,4 +1,4 @@ -from webapp.models import get_or_create, db, User +from webapp.models import JiraTask, User, db, get_or_create def get_or_create_user_id(user): @@ -6,13 +6,43 @@ def get_or_create_user_id(user): user_hrc_id = user.get("id") user_exists = User.query.filter_by(hrc_id=user_hrc_id).first() if not user_exists: - user_exists, _ = get_or_create(db.session, - User, - name=user.get("name"), - email=user.get("email"), - team=user.get("team"), - department=user.get("department"), - job_title=user.get("jobTitle"), - hrc_id=user_hrc_id) + user_exists, _ = get_or_create( + db.session, + User, + name=user.get("name"), + email=user.get("email"), + team=user.get("team"), + department=user.get("department"), + job_title=user.get("jobTitle"), + hrc_id=user_hrc_id, + ) return user_exists.id + + +def create_jira_task(app, task): + """ + Create a new issue on jira and add a record to the db + """ + # TODO: If an epic already exists for this request, add subtasks to it. + + jira = app.config["JIRA"] + issue = jira.create_issue( + due_date=task["due_date"], + reporter=task["reporter_id"], + webpage_id=task["webpage_id"], + issue_type=task["type"], + description=task["description"], + ) + + app.logger.info(issue) + + # Create jira task in the database + get_or_create( + db.session, + JiraTask, + jira_id=issue["id"], + webpage_id=task["webpage_id"], + user_id=task["reporter_id"], + status=issue["status"], + ) diff --git a/webapp/jira.py b/webapp/jira.py index 3063c104..031c9a7f 100644 --- a/webapp/jira.py +++ b/webapp/jira.py @@ -36,10 +36,20 @@ class JIRATaskResponseModel(BaseModel): class Jira: - headers = {"Accept": "application/json", "Content-Type": "application/json"} + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + EPIC = "10000" + SUBTASK = "10013" def __init__( - self, url: str, email: str, token: str, labels: str, copy_updates_epic: str + self, + url: str, + email: str, + token: str, + labels: str, + copy_updates_epic: str, ): """ Initialize the Jira object. @@ -56,13 +66,15 @@ def __init__( self.labels = labels self.copy_updates_epic = copy_updates_epic - def __request__(self, method: str, url: str, data: dict = {}, params: dict = {}): - if data == {}: - data = None + def __request__( + self, method: str, url: str, data: dict = {}, params: dict = {} + ): + if data: + data = json.dumps(data) response = requests.request( method, url, - data=json.dumps(data), + data=data, headers=self.headers, auth=self.auth, params=params, @@ -74,7 +86,7 @@ def __request__(self, method: str, url: str, data: dict = {}, params: dict = {}) f" {response.status_code}. Response: {response.text}" ) - json.loads(response.text) + return response.json() def get_reporter_jira_id(self, user_id): """ @@ -98,7 +110,7 @@ def get_reporter_jira_id(self, user_id): # Update the user in the database # user.jira_account_id = jira_user["accountId"] # db.session.commit() - return jira_user["accountId"] + return jira_user def find_user(self, query: str): """ @@ -113,10 +125,10 @@ def find_user(self, query: str): return self.__request__( method="GET", url=f"{self.url}/rest/api/3/user/search", - params={"query": f"{query}"}, + params={"query": query}, ) - def create_subtask( + def create_task( self, summary: str, issue_type: int, @@ -126,25 +138,23 @@ def create_subtask( due_date: datetime, ): """ - Creates a subtask in Jira. + Creates a task or subtask in Jira. Args: - summary (str): The summary of the subtask. - issue_type (int): The ID of the issue type for the subtask. - description (str): The description of the subtask. - parent (str): The key of the parent issue. If None, the subtask will be - created without a parent. - reporter (str): The ID of the reporter of the subtask. - due_date (datetime): The due date of the subtask. + summary (str): The summary of the task. + issue_type (int): The ID of the issue type for the task. + description (str): The description of the task. + parent (str): The key of the parent issue. If None, the task will + be created without a parent. + reporter (str): The ID of the reporter of the task. + due_date (datetime): The due date of the task. Returns: - dict: The response from the Jira API containing information about the - created subtask. + dict: The response from the Jira API containing information about + the created task. """ - if not parent: - parent = None - else: - parent = {"key": self.copy_updates_epic} + if parent: + parent = {"key": parent} payload = { "fields": { @@ -189,7 +199,8 @@ def create_issue( """Creates a new issue in Jira. Args: - issue_type (int): The type of the issue. 0 for Epic, 1 or 2 for Task. + issue_type (int): The type of the issue. 0 for Epic, 1 or 2 for + Task. description (str): The description of the issue. reporter (str): The ID of the reporter. webpage_id (int): The ID of the webpage. @@ -199,8 +210,11 @@ def create_issue( dict: The response from the Jira API. """ # Get the webpage - webpage = Webpage.query.filter_by(id=webpage_id).first() - if not webpage: + try: + webpage = Webpage.query.filter_by(id=webpage_id).first() + if not webpage: + raise Exception + except Exception: raise Exception(f"Webpage with ID {webpage_id} not found") # Get the reporter ID @@ -208,13 +222,12 @@ def create_issue( # Determine the correct issue type if issue_type == 0: - issue_type_id = "10002" # Epic parent = None summary = f"Copy update {webpage.name}" # Create epic - epic = self.create_subtask( + epic = self.create_task( summary=None, - issue_type=issue_type_id, + issue_type=self.EPIC, description=description, parent=parent, reporter=reporter, @@ -222,16 +235,17 @@ def create_issue( ) # Create subtasks for this epic for subtask_name in ["UX", "Visual", "Dev"]: - self.create_subtask( + self.create_task( summary=f"{subtask_name}-{summary}", - issue_type=issue_type_id, + issue_type=self.SUBTASK, # Task description=description, parent=epic["id"], reporter=reporter, due_date=due_date, ) + return epic + elif issue_type == 1 or issue_type == 2: - issue_type_id = "10001" # Task parent = {"key": self.copy_updates_epic} # Determine summary message @@ -242,9 +256,9 @@ def create_issue( elif issue_type == 2: summary = f"New webpage for {webpage.name}" - return self.create_subtask( + return self.create_task( summary=summary, - issue_type=issue_type_id, + issue_type=self.SUBTASK, description=description, parent=parent, reporter=reporter, diff --git a/webapp/schemas.py b/webapp/schemas.py index ee5267ef..7f39ed15 100644 --- a/webapp/schemas.py +++ b/webapp/schemas.py @@ -18,14 +18,18 @@ def wrapper(*args, **kwargs): return decorator -{ +class ChangesRequestModel(BaseModel): + due_date: str + reporter_id: int + webpage_id: int + type: int + description: str + + +changes = { "due_date": "2022-01-01", "reporter_id": 1, "webpage_id": 1, "type": 0, "description": "This is a description", } - - -class ResponseModel(BaseModel): - message: str = "OK" From db48238fbae6ecf24b4b2a311f8d7177801fc99c Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Fri, 13 Sep 2024 11:37:56 +0300 Subject: [PATCH 03/15] Fixed ci params typo --- .github/workflows/CI.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 3e4f5ecc..b3ac3ac0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -159,11 +159,11 @@ jobs: -e GH_TOKEN=token \ -e REPO_ORG=https://github.com/canonical \ -e SQLALCHEMY_DATABASE_URI=postgresql://postgres:postgres@localhost:5432/postgres \ - -e JIRA_EMAIL=example@canonical.com - -e JIRA_TOKEN=jiratoken - -e JIRA_URL=https://example.atlassian.net - -e JIRA_LABELS=somelabel - -e JIRA_COPY_UPDATES_EPIC=WD-9999999 + -e JIRA_EMAIL=example@canonical.com \ + -e JIRA_TOKEN=jiratoken \ + -e JIRA_URL=https://example.atlassian.net \ + -e JIRA_LABELS=somelabel \ + -e JIRA_COPY_UPDATES_EPIC=WD-9999999 \ --network host \ websites-content-system & sleep 1 curl --head --fail --retry-delay 1 --retry 30 --retry-connrefused http://localhost From ee9a7dcc9e162c560c93053c8d9ee40b555276b6 Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Fri, 13 Sep 2024 11:42:36 +0300 Subject: [PATCH 04/15] Removed task utils --- webapp/schemas.py | 9 --------- webapp/utils.py | 25 ------------------------- 2 files changed, 34 deletions(-) delete mode 100644 webapp/utils.py diff --git a/webapp/schemas.py b/webapp/schemas.py index 7f39ed15..f5cadad0 100644 --- a/webapp/schemas.py +++ b/webapp/schemas.py @@ -24,12 +24,3 @@ class ChangesRequestModel(BaseModel): webpage_id: int type: int description: str - - -changes = { - "due_date": "2022-01-01", - "reporter_id": 1, - "webpage_id": 1, - "type": 0, - "description": "This is a description", -} diff --git a/webapp/utils.py b/webapp/utils.py deleted file mode 100644 index 3aaa3355..00000000 --- a/webapp/utils.py +++ /dev/null @@ -1,25 +0,0 @@ -import inspect -from multiprocessing import Process, Queue -from typing import Callable - -from flask import Flask - - -def enqueue_task( - func: Callable, app: Flask, queue: Queue, task_locks: dict, *args, **kwargs -): - """ - Start the task in a separate process. - """ - # func must take app, task queue, and locks as arguments - frame = inspect.currentframe() - _, _, _, values = inspect.getargvalues(frame) - for i in ["app", "queue", "task_locks"]: - if i not in values: - raise ValueError( - "Function must take app, task queue, and locks as arguments" - ) - - Process( - target=func, args=(app, queue, task_locks, *args), kwargs=kwargs - ).start() From 25cb71048d17f263d74d85e1dbcbba1b794535a9 Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Tue, 17 Sep 2024 11:55:42 +0300 Subject: [PATCH 05/15] Use explicit names for request types --- webapp/app.py | 5 ++-- webapp/helper.py | 4 +-- webapp/jira.py | 67 ++++++++++++++++++++++++++---------------------- 3 files changed, 40 insertions(+), 36 deletions(-) diff --git a/webapp/app.py b/webapp/app.py index cb8d6a71..f7837821 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -5,7 +5,7 @@ from flask_pydantic import validate from webapp import create_app -from webapp.helper import get_or_create_user_id +from webapp.helper import create_jira_task, get_or_create_user_id from webapp.models import Reviewer, Webpage, db, get_or_create from webapp.schemas import ( ChangesRequestModel, @@ -129,10 +129,9 @@ def set_owner(): @app.route("/request-changes", methods=["POST"]) -# @login_required +@login_required @validate() def request_changes(body: ChangesRequestModel): - from webapp.helper import create_jira_task # Make a request to JIRA to create a task try: diff --git a/webapp/helper.py b/webapp/helper.py index b075ecca..00d0f7ce 100644 --- a/webapp/helper.py +++ b/webapp/helper.py @@ -29,9 +29,9 @@ def create_jira_task(app, task): jira = app.config["JIRA"] issue = jira.create_issue( due_date=task["due_date"], - reporter=task["reporter_id"], + reporter_id=task["reporter_id"], webpage_id=task["webpage_id"], - issue_type=task["type"], + request_type=task["type"], description=task["description"], ) diff --git a/webapp/jira.py b/webapp/jira.py index 031c9a7f..61f239fa 100644 --- a/webapp/jira.py +++ b/webapp/jira.py @@ -8,7 +8,7 @@ from pydantic import BaseModel from requests.auth import HTTPBasicAuth -from webapp.models import User, Webpage +from webapp.models import User, Webpage, db class Issue(BaseModel): @@ -42,6 +42,9 @@ class Jira: } EPIC = "10000" SUBTASK = "10013" + COPY_UPDATE = 0 + PAGE_REFRESH = 1 + NEW_WEBPAGE = 2 def __init__( self, @@ -108,9 +111,9 @@ def get_reporter_jira_id(self, user_id): if not jira_user: raise ValueError(f"User with email {user.email} not found in Jira") # Update the user in the database - # user.jira_account_id = jira_user["accountId"] - # db.session.commit() - return jira_user + user.jira_account_id = jira_user[0]["accountId"] + db.session.commit() + return jira_user[0]["accountId"] def find_user(self, query: str): """ @@ -134,7 +137,7 @@ def create_task( issue_type: int, description: str, parent: str, - reporter: str, + reporter_jira_id: str, due_date: datetime, ): """ @@ -146,7 +149,7 @@ def create_task( description (str): The description of the task. parent (str): The key of the parent issue. If None, the task will be created without a parent. - reporter (str): The ID of the reporter of the task. + reporter_jira_id (str): The ID of the reporter of the task. due_date (datetime): The due date of the task. Returns: @@ -176,11 +179,11 @@ def create_task( "summary": summary, "issuetype": {"id": issue_type}, "labels": self.labels, - "reporter": {"id": reporter}, - "duedate": due_date.date.isoformat(), + "reporter": {"id": reporter_jira_id}, + "duedate": due_date, "parent": parent, - "project": {"id": "10000"}, # Hardcoded for now - "components": [{"id": "10000"}], # Hardcoded for now + "project": {"id": "10492"}, # Web and Design-ENG + "components": [{"id": "12655"}], # Sites tribe }, "update": {}, } @@ -190,19 +193,19 @@ def create_task( def create_issue( self, - issue_type: int, + request_type: int, description: str, - reporter: str, + reporter_id: str, webpage_id: int, due_date: datetime, ): """Creates a new issue in Jira. Args: - issue_type (int): The type of the issue. 0 for Epic, 1 or 2 for + request_type (int): The type of the request. 0 for Epic, 1 or 2 for Task. description (str): The description of the issue. - reporter (str): The ID of the reporter. + reporter_id (str): The ID of the reporter. webpage_id (int): The ID of the webpage. due_date (datetime): The due date of the issue. @@ -218,50 +221,52 @@ def create_issue( raise Exception(f"Webpage with ID {webpage_id} not found") # Get the reporter ID - reporter = self.get_reporter_jira_id(reporter) + reporter_jira_id = self.get_reporter_jira_id(reporter_id) - # Determine the correct issue type - if issue_type == 0: - parent = None + # Determine summary message + if request_type == self.COPY_UPDATE: summary = f"Copy update {webpage.name}" + elif request_type == self.PAGE_REFRESH: + summary = f"Page refresh for {webpage.name}" + elif request_type == self.NEW_WEBPAGE: + summary = f"New webpage for {webpage.name}" + + # Create the issue depending on the request type + if ( + request_type == self.NEW_WEBPAGE + or request_type == self.PAGE_REFRESH + ): + parent = None # Create epic epic = self.create_task( summary=None, issue_type=self.EPIC, description=description, parent=parent, - reporter=reporter, + reporter_id=reporter_jira_id, due_date=due_date, ) # Create subtasks for this epic for subtask_name in ["UX", "Visual", "Dev"]: self.create_task( summary=f"{subtask_name}-{summary}", - issue_type=self.SUBTASK, # Task + issue_type=self.SUBTASK, description=description, parent=epic["id"], - reporter=reporter, + reporter_id=reporter_jira_id, due_date=due_date, ) return epic - elif issue_type == 1 or issue_type == 2: + elif request_type == self.COPY_UPDATE: parent = {"key": self.copy_updates_epic} - # Determine summary message - if issue_type == 0: - summary = f"Copy update {webpage.name}" - elif issue_type == 1: - summary = f"Page refresh for {webpage.name}" - elif issue_type == 2: - summary = f"New webpage for {webpage.name}" - return self.create_task( summary=summary, issue_type=self.SUBTASK, description=description, parent=parent, - reporter=reporter, + reporter_id=reporter_jira_id, due_date=due_date, ) From 5011502ef7155e5437109f0ea5b9dfd6b57f8e38 Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Tue, 17 Sep 2024 12:31:39 +0300 Subject: [PATCH 06/15] Updated epic fields --- webapp/helper.py | 3 --- webapp/jira.py | 30 +++++++++++++++++------------- webapp/models.py | 10 +++++++++- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/webapp/helper.py b/webapp/helper.py index 00d0f7ce..6778f3d0 100644 --- a/webapp/helper.py +++ b/webapp/helper.py @@ -35,8 +35,6 @@ def create_jira_task(app, task): description=task["description"], ) - app.logger.info(issue) - # Create jira task in the database get_or_create( db.session, @@ -44,5 +42,4 @@ def create_jira_task(app, task): jira_id=issue["id"], webpage_id=task["webpage_id"], user_id=task["reporter_id"], - status=issue["status"], ) diff --git a/webapp/jira.py b/webapp/jira.py index 61f239fa..35b4902e 100644 --- a/webapp/jira.py +++ b/webapp/jira.py @@ -83,13 +83,13 @@ def __request__( params=params, ) - if response.status_code != 200: - raise Exception( - "Failed to make a request to Jira. Status code:" - f" {response.status_code}. Response: {response.text}" - ) + if response.status_code == 200 or response.status_code == 201: + return response.json() - return response.json() + raise Exception( + "Failed to make a request to Jira. Status code:" + f" {response.status_code}. Response: {response.text}" + ) def get_reporter_jira_id(self, user_id): """ @@ -183,7 +183,10 @@ def create_task( "duedate": due_date, "parent": parent, "project": {"id": "10492"}, # Web and Design-ENG - "components": [{"id": "12655"}], # Sites tribe + "components": [ + {"id": "12655"}, + {"id": "12628"}, + ], # Sites Tribe, Infrastructure }, "update": {}, } @@ -239,34 +242,35 @@ def create_issue( parent = None # Create epic epic = self.create_task( - summary=None, + summary=summary, issue_type=self.EPIC, description=description, parent=parent, - reporter_id=reporter_jira_id, + reporter_jira_id=reporter_jira_id, due_date=due_date, ) + # Create subtasks for this epic for subtask_name in ["UX", "Visual", "Dev"]: self.create_task( summary=f"{subtask_name}-{summary}", issue_type=self.SUBTASK, description=description, - parent=epic["id"], - reporter_id=reporter_jira_id, + parent=epic["key"], + reporter_jira_id=reporter_jira_id, due_date=due_date, ) return epic elif request_type == self.COPY_UPDATE: - parent = {"key": self.copy_updates_epic} + parent = self.copy_updates_epic return self.create_task( summary=summary, issue_type=self.SUBTASK, description=description, parent=parent, - reporter_id=reporter_jira_id, + reporter_jira_id=reporter_jira_id, due_date=due_date, ) diff --git a/webapp/models.py b/webapp/models.py index ed66c951..e3116de4 100644 --- a/webapp/models.py +++ b/webapp/models.py @@ -113,6 +113,14 @@ class Reviewer(db.Model, DateTimeMixin): user = relationship("User", back_populates="reviewers") webpages = relationship("Webpage", back_populates="reviewers") +class JIRATaskStatus(enum.Enum): + TRIAGED = "TRIAGED" + UNTRIAGED = "UNTRIAGED" + BLOCKED = "BLOCKED" + IN_PROGRESS = "IN_PROGRESS" + TO_BE_DEPLOYED = "TO_BE_DEPLOYED" + DONE = "DONE" + REJECTED = "REJECTED" class JiraTask(db.Model, DateTimeMixin): __tablename__ = "jira_tasks" @@ -121,7 +129,7 @@ class JiraTask(db.Model, DateTimeMixin): jira_id: int = Column(Integer) webpage_id: int = Column(Integer, ForeignKey("webpages.id")) user_id: int = Column(Integer, ForeignKey("users.id")) - status: str = Column(String) # Will be filled from Jira API + status: str = Column(Enum(JIRATaskStatus), default=JIRATaskStatus.TRIAGED) created_at: str = Column(String) webpages = relationship("Webpage", back_populates="jira_tasks") From 5fb521c23f97161f87db1e661fee575ae3b5d1e3 Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Tue, 17 Sep 2024 12:38:14 +0300 Subject: [PATCH 07/15] Updated readme --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index e59a9ec8..9ddb4b91 100644 --- a/README.md +++ b/README.md @@ -120,4 +120,20 @@ $ docker inspect | grep IPAddress "name": "/", "title": null } +} +``` + +#### Making a webpage update request + +
+ POST /request-changes +
+ +```json +{ + "due_date": "2022-01-01", + "reporter_id": 1, + "webpage_id": 31, + "type": 1, + "description": "This is a description", } \ No newline at end of file From 14bb8e1c0f1c18a22e0b07458722f7a06d55ce62 Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Tue, 17 Sep 2024 12:57:02 +0300 Subject: [PATCH 08/15] Removed unused type models --- webapp/jira.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/webapp/jira.py b/webapp/jira.py index 35b4902e..520f4fa7 100644 --- a/webapp/jira.py +++ b/webapp/jira.py @@ -2,39 +2,13 @@ import json from datetime import datetime -from typing import Literal, Optional import requests -from pydantic import BaseModel from requests.auth import HTTPBasicAuth from webapp.models import User, Webpage, db -class Issue(BaseModel): - project: str - summary: str - description: str - priority: str - issuetype: str - - -class JIRATaskRequestModel(BaseModel): - project: Literal["Web & Design - ENG"] - due_date: datetime - reporter: str - components: str - labels: str - description: Optional[str] = None - - -class JIRATaskResponseModel(BaseModel): - id: int - age: int - name: str - nickname: Optional[str] = None - - class Jira: headers = { "Accept": "application/json", From 66e74b148c2594321c94cc808d0718a746ad084f Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Thu, 19 Sep 2024 10:38:06 +0300 Subject: [PATCH 09/15] Use issue key instead of id --- webapp/helper.py | 2 +- webapp/jira.py | 6 +++--- webapp/models.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/webapp/helper.py b/webapp/helper.py index 6778f3d0..5abddce9 100644 --- a/webapp/helper.py +++ b/webapp/helper.py @@ -39,7 +39,7 @@ def create_jira_task(app, task): get_or_create( db.session, JiraTask, - jira_id=issue["id"], + jira_id=issue["key"], webpage_id=task["webpage_id"], user_id=task["reporter_id"], ) diff --git a/webapp/jira.py b/webapp/jira.py index 520f4fa7..83ffa456 100644 --- a/webapp/jira.py +++ b/webapp/jira.py @@ -158,9 +158,9 @@ def create_task( "parent": parent, "project": {"id": "10492"}, # Web and Design-ENG "components": [ - {"id": "12655"}, - {"id": "12628"}, - ], # Sites Tribe, Infrastructure + {"id": "12655"}, # Sites Tribe + {"id": "12628"}, # Infrastructure + ], }, "update": {}, } diff --git a/webapp/models.py b/webapp/models.py index e3116de4..01f32cdf 100644 --- a/webapp/models.py +++ b/webapp/models.py @@ -113,7 +113,7 @@ class Reviewer(db.Model, DateTimeMixin): user = relationship("User", back_populates="reviewers") webpages = relationship("Webpage", back_populates="reviewers") -class JIRATaskStatus(enum.Enum): +class JIRATaskStatus: TRIAGED = "TRIAGED" UNTRIAGED = "UNTRIAGED" BLOCKED = "BLOCKED" @@ -129,7 +129,7 @@ class JiraTask(db.Model, DateTimeMixin): jira_id: int = Column(Integer) webpage_id: int = Column(Integer, ForeignKey("webpages.id")) user_id: int = Column(Integer, ForeignKey("users.id")) - status: str = Column(Enum(JIRATaskStatus), default=JIRATaskStatus.TRIAGED) + status: str = Column(String, default=JIRATaskStatus.TRIAGED) created_at: str = Column(String) webpages = relationship("Webpage", back_populates="jira_tasks") From b52d253cdd6c5b9ead871ab41ceff8d824d9a7af Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Thu, 19 Sep 2024 10:42:20 +0300 Subject: [PATCH 10/15] Use string for key --- migrations/versions/7b0c727c0201_.py | 38 ++++++++++++++++++++++++++++ webapp/models.py | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/7b0c727c0201_.py diff --git a/migrations/versions/7b0c727c0201_.py b/migrations/versions/7b0c727c0201_.py new file mode 100644 index 00000000..9df1d5ff --- /dev/null +++ b/migrations/versions/7b0c727c0201_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 7b0c727c0201 +Revises: c4073a9759fc +Create Date: 2024-09-19 10:41:48.215628 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7b0c727c0201' +down_revision = 'c4073a9759fc' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('jira_tasks', schema=None) as batch_op: + batch_op.alter_column('jira_id', + existing_type=sa.INTEGER(), + type_=sa.String(), + existing_nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('jira_tasks', schema=None) as batch_op: + batch_op.alter_column('jira_id', + existing_type=sa.String(), + type_=sa.INTEGER(), + existing_nullable=True) + + # ### end Alembic commands ### diff --git a/webapp/models.py b/webapp/models.py index 01f32cdf..b7f32ce4 100644 --- a/webapp/models.py +++ b/webapp/models.py @@ -126,7 +126,7 @@ class JiraTask(db.Model, DateTimeMixin): __tablename__ = "jira_tasks" id: int = Column(Integer, primary_key=True) - jira_id: int = Column(Integer) + jira_id: str = Column(String) webpage_id: int = Column(Integer, ForeignKey("webpages.id")) user_id: int = Column(Integer, ForeignKey("users.id")) status: str = Column(String, default=JIRATaskStatus.TRIAGED) From 112547eec9413c083802667e774ac1a5d8437eba Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Thu, 19 Sep 2024 14:34:24 +0300 Subject: [PATCH 11/15] Update webapp/jira.py Co-authored-by: Akbar Abdrakhmanov --- webapp/jira.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/jira.py b/webapp/jira.py index 83ffa456..b6fb7be1 100644 --- a/webapp/jira.py +++ b/webapp/jira.py @@ -227,7 +227,7 @@ def create_issue( # Create subtasks for this epic for subtask_name in ["UX", "Visual", "Dev"]: self.create_task( - summary=f"{subtask_name}-{summary}", + summary=f"{subtask_name} - {summary}", issue_type=self.SUBTASK, description=description, parent=epic["key"], From e5b788a97a18ae3463066cc07415f3f22020f37f Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Thu, 19 Sep 2024 14:38:51 +0300 Subject: [PATCH 12/15] Removed created_at field override --- webapp/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/models.py b/webapp/models.py index b7f32ce4..e44ab5f6 100644 --- a/webapp/models.py +++ b/webapp/models.py @@ -130,7 +130,6 @@ class JiraTask(db.Model, DateTimeMixin): webpage_id: int = Column(Integer, ForeignKey("webpages.id")) user_id: int = Column(Integer, ForeignKey("users.id")) status: str = Column(String, default=JIRATaskStatus.TRIAGED) - created_at: str = Column(String) webpages = relationship("Webpage", back_populates="jira_tasks") user = relationship("User", back_populates="jira_tasks") From 8b5b914719c1a36fe77326b73a701dbf1dca2f32 Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Thu, 19 Sep 2024 14:49:37 +0300 Subject: [PATCH 13/15] Reformat exceptions as 500 errors --- webapp/app.py | 2 +- webapp/jira.py | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/webapp/app.py b/webapp/app.py index f7837821..47ee01c5 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -137,6 +137,6 @@ def request_changes(body: ChangesRequestModel): try: create_jira_task(app, body.model_dump()) except Exception as e: - return jsonify(e), 500 + return jsonify(str(e)), 500 return jsonify("Task created successfully"), 201 diff --git a/webapp/jira.py b/webapp/jira.py index b6fb7be1..ee6afd60 100644 --- a/webapp/jira.py +++ b/webapp/jira.py @@ -77,9 +77,11 @@ def get_reporter_jira_id(self, user_id): """ # Try to get the user from the database user = User.query.filter_by(id=user_id).first() - if user: - if user.jira_account_id: - return user.jira_account_id + if not user: + raise ValueError(f"User with ID {user_id} not found") + # If the user already has a Jira account ID, return it + if user.jira_account_id: + return user.jira_account_id # Otherwise get it from jira jira_user = self.find_user(user.email) if not jira_user: @@ -190,11 +192,8 @@ def create_issue( dict: The response from the Jira API. """ # Get the webpage - try: - webpage = Webpage.query.filter_by(id=webpage_id).first() - if not webpage: - raise Exception - except Exception: + webpage = Webpage.query.filter_by(id=webpage_id).first() + if not webpage: raise Exception(f"Webpage with ID {webpage_id} not found") # Get the reporter ID From a81779b929534433073341c7eda766a503d55485 Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Thu, 19 Sep 2024 14:52:33 +0300 Subject: [PATCH 14/15] Removed infrastructure component --- webapp/jira.py | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/jira.py b/webapp/jira.py index ee6afd60..3d8475bb 100644 --- a/webapp/jira.py +++ b/webapp/jira.py @@ -161,7 +161,6 @@ def create_task( "project": {"id": "10492"}, # Web and Design-ENG "components": [ {"id": "12655"}, # Sites Tribe - {"id": "12628"}, # Infrastructure ], }, "update": {}, From 773e191ee94761af78f3d2db271f2847cfda368c Mon Sep 17 00:00:00 2001 From: Olwe Samuel Date: Thu, 19 Sep 2024 14:57:55 +0300 Subject: [PATCH 15/15] Added deployment config --- konf/site.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/konf/site.yaml b/konf/site.yaml index abdbfd64..c8644386 100644 --- a/konf/site.yaml +++ b/konf/site.yaml @@ -28,6 +28,25 @@ env: key: token name: directory-api + - name: JIRA_EMAIL + secretKeyRef: + key: jira-email + name: cms-jira + + - name: JIRA_TOKEN + secretKeyRef: + key: jira-token + name: cms-jira + + - name: JIRA_URL + value: "https://warthogs.atlassian.net" + + - name: JIRA_LABELS + value: "sites_BAU" + + - name: JIRA_COPY_UPDATES_EPIC + value: "WD-12643" + # Overrides for production production: replicas: 1