diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml-temp-disabled similarity index 100% rename from .github/workflows/build.yaml rename to .github/workflows/build.yaml-temp-disabled diff --git a/.github/workflows/check-formatting.yaml b/.github/workflows/check-formatting.yaml-temp-disabled similarity index 100% rename from .github/workflows/check-formatting.yaml rename to .github/workflows/check-formatting.yaml-temp-disabled diff --git a/.github/workflows/helpers.js b/.github/workflows/helpers.js new file mode 100644 index 000000000..b90cfead8 --- /dev/null +++ b/.github/workflows/helpers.js @@ -0,0 +1,42 @@ +// Those env variables are set by an github action automatically +// For local testing, you should test on your fork. +const owner = process.env.REPO_OWNER || ''; // example owner: adobecom +const repo = process.env.REPO_NAME || ''; // example repo name: mas +const auth = process.env.GH_TOKEN || ''; // https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens + +const getLocalConfigs = async () => { + if (!owner || !repo || !auth) { + throw new Error(`Create a .env file on the root of the project with credentials. +Then run: node --env-file=.env .github/workflows/snow-pr-comment.js`); + } + + const { Octokit } = await import('@octokit/rest'); + return { + github: { + rest: new Octokit({ auth }), + repos: { + createDispatchEvent: () => console.log('local mock createDispatch'), + }, + }, + context: { + repo: { + owner, + repo, + }, + }, + }; +}; + +const slackNotification = (text, webhook) => { + console.log(text); + return fetch(webhook || process.env.SLACK_WEBHOOK_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text }), + }); +}; + +export { getLocalConfigs, slackNotification }; + diff --git a/.github/workflows/io-deploy.yaml b/.github/workflows/io-deploy.yaml-temp-disabled similarity index 100% rename from .github/workflows/io-deploy.yaml rename to .github/workflows/io-deploy.yaml-temp-disabled diff --git a/.github/workflows/io-merge.yaml b/.github/workflows/io-merge.yaml-temp-disabled similarity index 100% rename from .github/workflows/io-merge.yaml rename to .github/workflows/io-merge.yaml-temp-disabled diff --git a/.github/workflows/io-pr.yaml b/.github/workflows/io-pr.yaml-temp-disabled similarity index 100% rename from .github/workflows/io-pr.yaml rename to .github/workflows/io-pr.yaml-temp-disabled diff --git a/.github/workflows/io-studio-deploy.yaml b/.github/workflows/io-studio-deploy.yaml-disabled similarity index 100% rename from .github/workflows/io-studio-deploy.yaml rename to .github/workflows/io-studio-deploy.yaml-disabled diff --git a/.github/workflows/io-studio-merge.yaml b/.github/workflows/io-studio-merge.yaml-disabled similarity index 100% rename from .github/workflows/io-studio-merge.yaml rename to .github/workflows/io-studio-merge.yaml-disabled diff --git a/.github/workflows/io-studio-pr.yaml b/.github/workflows/io-studio-pr.yaml-disabled similarity index 100% rename from .github/workflows/io-studio-pr.yaml rename to .github/workflows/io-studio-pr.yaml-disabled diff --git a/.github/workflows/io-studio-tests.yaml b/.github/workflows/io-studio-tests.yaml-disabled similarity index 100% rename from .github/workflows/io-studio-tests.yaml rename to .github/workflows/io-studio-tests.yaml-disabled diff --git a/.github/workflows/io-tests.yaml b/.github/workflows/io-tests.yaml-temp-disabled similarity index 100% rename from .github/workflows/io-tests.yaml rename to .github/workflows/io-tests.yaml-temp-disabled diff --git a/.github/workflows/jira-link-pr.js b/.github/workflows/jira-link-pr.js-temp-disabled similarity index 100% rename from .github/workflows/jira-link-pr.js rename to .github/workflows/jira-link-pr.js-temp-disabled diff --git a/.github/workflows/jira-link-pr.yml b/.github/workflows/jira-link-pr.yml-temp-disabled similarity index 100% rename from .github/workflows/jira-link-pr.yml rename to .github/workflows/jira-link-pr.yml-temp-disabled diff --git a/.github/workflows/ost-products.yaml b/.github/workflows/ost-products.yaml-temp-disabled similarity index 100% rename from .github/workflows/ost-products.yaml rename to .github/workflows/ost-products.yaml-temp-disabled diff --git a/.github/workflows/run-milo-nala-maslibs.yml b/.github/workflows/run-milo-nala-maslibs.yml-temp-disabled similarity index 100% rename from .github/workflows/run-milo-nala-maslibs.yml rename to .github/workflows/run-milo-nala-maslibs.yml-temp-disabled diff --git a/.github/workflows/run-nala-daily.yml b/.github/workflows/run-nala-daily.yml-temp-disabled similarity index 100% rename from .github/workflows/run-nala-daily.yml rename to .github/workflows/run-nala-daily.yml-temp-disabled diff --git a/.github/workflows/run-nala-milolibs.yaml b/.github/workflows/run-nala-milolibs.yaml-temp-disabled similarity index 100% rename from .github/workflows/run-nala-milolibs.yaml rename to .github/workflows/run-nala-milolibs.yaml-temp-disabled diff --git a/.github/workflows/run-nala.yml b/.github/workflows/run-nala.yml-temp-disabled similarity index 100% rename from .github/workflows/run-nala.yml rename to .github/workflows/run-nala.yml-temp-disabled diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml-temp-disabled similarity index 100% rename from .github/workflows/run-tests.yaml rename to .github/workflows/run-tests.yaml-temp-disabled diff --git a/.github/workflows/servicenow.py b/.github/workflows/servicenow.py new file mode 100644 index 000000000..f5c34bc96 --- /dev/null +++ b/.github/workflows/servicenow.py @@ -0,0 +1,318 @@ +import datetime +import json +import os +import random +import sys +import time +import requests + +APPLICATION_JSON = "application/json" +CMR_RETRIEVAL_ERROR = "CMR ID Retrieval Operation failed..." +POST_FAILURE_MESSAGE = "POST failed with response code: " + +# Set URLs based on ENVIRONMENT variable +ENVIRONMENT = os.environ.get('ENVIRONMENT', 'stage') + +if ENVIRONMENT == 'prod': + # Production URLs + IMS_URL = 'https://ims-na1.adobelogin.com/ims/token' + SERVICENOW_CMR_URL = 'https://ipaasapi.adobe-services.com/change_management/changes' + SERVICENOW_GET_CMR_URL = 'https://ipaasapi.adobe-services.com/change_management/transactions/' +else: + # Stage URLs (default) + IMS_URL = 'https://ims-na1-stg1.adobelogin.com/ims/token' + SERVICENOW_CMR_URL = 'https://ipaasapi-stage.adobe-services.com/change_management/changes' + SERVICENOW_GET_CMR_URL = 'https://ipaasapi-stage.adobe-services.com/change_management/transactions/' + +print(f"Using ENVIRONMENT: {ENVIRONMENT}") +print(f"IMS_URL: {IMS_URL}") +print(f"SERVICENOW_CMR_URL: {SERVICENOW_CMR_URL}") + +output_file = open(os.environ['GITHUB_OUTPUT'], 'a') + +def _search_value(value, target_string): + if isinstance(value, str): + return target_string in value + if isinstance(value, (dict, list)): + return find_string_in_json(value, target_string) + return False + +def find_string_in_json(json_data, target_string): + """ + Finds a target string in a JSON object. + + Args: + json_data (dict or list): The JSON data to search. + target_string (str): The string to find. + + Returns: + bool: True if the string is found, False otherwise. + """ + if isinstance(json_data, dict): + return any(_search_value(value, target_string) for value in json_data.values()) + if isinstance(json_data, list): + return any(_search_value(item, target_string) for item in json_data) + return False + +def backoff_with_timeout(operation, max_retries=5, base_delay=1, max_delay=60, timeout=300): + """ + Smart back off for operations that may require multiple attempts with increasing intervals between each + execution until a successful return. Allows max attempts and/or timeout to ensure infinite looping doesn't + happen. + + Args: + operation (_type_): The operation you would like to attempt in intervals. + max_retries (int, optional): The max amount of attempts allowed for the smart backoff. Defaults to 5. + base_delay (int, optional): The starting delay for the random amount to calculate intervals. Defaults to 1. + max_delay (int, optional): The maximum delay for the random amount to calculate intervals. Defaults to 60. + timeout (int, optional): The max amount of time allowed for the smart backoff. Defaults to 300. + + Raises: + TimeoutError: If the max amount of attempts or timeout is reached before a successful operation return happens, a timeout exception is thrown. + + Returns: + _type_: The return value from the sent in operation that requires a smart backoff. + """ + + start_time = time.time() + attempts = 0 + while attempts <= max_retries and (time.time() - start_time) < timeout: + try: + print("Attempting ServiceNow API operation...") + return operation() # Attempt the operation + except Exception as e: + attempts += 1 + if attempts > max_retries or (time.time() - start_time) >= timeout: + raise # Re-raise the exception if max retries or timeout is reached + + delay = min(base_delay * (2 ** (attempts - 1)), max_delay) + random.uniform(0, 0.1 * base_delay) + time.sleep(delay) + raise TimeoutError("Operation timed out after {} seconds or {} retries, whatever came first.".format(timeout, max_retries)) + +def get_cmr_id_operation(): + """ + Operation to retrieve a Change Management Request ID from ServiceNow + + Raises: + Exception: If the GET request returns a non 200 response. + Exception: If the GET request is successful but returns a error message payload. + Exception: If the GET request is successful but returns an "Unknown" status message in payload. + + Returns: + _type_: The Change ID from the JSON payload + """ + response = requests.get(servicenow_get_cmr_url, headers=headers) + JSON_PARSE = json.loads(response.text) + + if response.status_code != 200: + print(f"GET failed with response code: {response.status_code}") + print(response.text) + raise Exception(CMR_RETRIEVAL_ERROR) + elif find_string_in_json(JSON_PARSE, "error"): + print(f"CMR ID retrieval failed with response code: {response.status_code}") + print(response.text) + raise Exception(CMR_RETRIEVAL_ERROR) + else: + if find_string_in_json(JSON_PARSE, "Unknown"): + print(f"CMR ID retrieval failed with response code: {response.status_code}") + print(response.text) + raise Exception(CMR_RETRIEVAL_ERROR) + + print(f"CMR ID retrieval was successful: {response.status_code}") + print(response.text) + + return JSON_PARSE["result"]["changeId"] + +# Execute Script logic: +# python3 servicenow.py +if __name__ == "__main__": + if os.environ['PR_STATE'] == 'open': + # PR is open. Create a new CMR, write the transaction ID to the PR comment, and send a Slack notification. + print("Starting CMR Action...") + + print("Setting Planned Maintenance Time Windows for CMR...") + start_time = int((datetime.datetime.now() + datetime.timedelta(seconds = 10)).timestamp()) + end_time = int((datetime.datetime.now() + datetime.timedelta(minutes = 10)).timestamp()) + + print(f"Set start time for CMR: {start_time}") + print(f"Set end time for CMR: {end_time}") + + print("Set Release Summary for CMR...") + cmr_path = os.environ.get('CMR_PATH', '') + path_label = f" [{cmr_path.upper()}]" if cmr_path else "" + release_title = f"{os.environ['PR_TITLE']}{path_label}" + release_details = os.environ['PR_BODY'] + pr_num = os.environ['PR_NUMBER'] + pr_link = os.environ['PR_LINK'] + pr_created = os.environ['PR_CREATED_AT'] + release_summary = f"Path: {cmr_path}/\nRelease_Details: {release_details} \n\nPull Request Number: {pr_num} \nPull Request Link: {pr_link} \nPull Request Created At: {pr_created} \nSee the closure notes for merge date." + + print("Getting IMS Token") + headers = {"Content-Type":"multipart/form-data"} + data = { + 'client_id': os.environ['IMSACCESS_CLIENT_ID'], + 'client_secret': os.environ['IMSACCESS_CLIENT_SECRET'], + 'grant_type': "authorization_code", + 'code': os.environ['IMSACCESS_AUTH_CODE'] + } + response = requests.post(IMS_URL, data=data) + json_parse = json.loads(response.text) + + if response.status_code != 200: + print(f"{POST_FAILURE_MESSAGE} {response.status_code}") + print(response.text) + sys.exit(1) + elif find_string_in_json(json_parse, "error"): + print(f"IMS token request failed with response code: {response.status_code}") + print(response.text) + sys.exit(1) + else: + print(f"IMS token request was successful: {response.status_code}") + token = json_parse["access_token"] + + print("Create CMR in ServiceNow...") + + headers = { + "Accept": APPLICATION_JSON, + "Authorization":token, + "Content-Type": APPLICATION_JSON, + "api_key":os.environ['IPAAS_KEY'] + } + # Get single instance ID for this CMR path + instance_id = os.environ['SNOW_INSTANCE_ID'].strip() + instance_ids = [instance_id] if instance_id else [] + + data = { + "title":release_title, + "description":release_summary, + "instanceIds": instance_ids, + "plannedStartDate": start_time, + "plannedEndDate": end_time, + "coordinator": "lukianet@adobe.com", + "customerImpact": "No Impact", + "changeReason": [ "New Features", "Bug Fixes", "Enhancement", "Maintenance", "Security" ], + "preProductionTestingType": [ "End-to-End", "Functional", "Integrations", "QA", "Regression", "UAT", "Unit Test" ], + "backoutPlanType": "Roll back", + "approvedBy": [ "casalino@adobe.com", "jmichnow@adobe.com", "mauchley@adobe.com", "bbalakrishna@adobe.com", "tuscany@adobe.com", "brahmbha@adobe.com" ], + "testPlan": "Test plan is documented in the PR link in the mas repository above. See the PR's merge checks to see Unit and Nala testing.", + "implementationPlan": "The change will be released as part of the continuous deployment of mas's production branch, i.e., \"main\"", + "backoutPlan": "Revert merge to the mas production branch by creating a revert commit.", "testResults": "Changes are tested and validated successfully in staging environment. Please see the link of the PR in the description for the test results and/or the \"#merch-at-scale\" slack channel." + } + response = requests.post(SERVICENOW_CMR_URL, headers=headers, json=data) + json_parse = json.loads(response.text) + + if response.status_code != 200: + print(f"{POST_FAILURE_MESSAGE} {response.status_code}") + print(response.text) + sys.exit(1) + elif find_string_in_json(json_parse, "error"): + print(f"CMR creation failed with response code: {response.status_code}") + print(response.text) + sys.exit(1) + else: + print(f"CMR creation was successful: {response.status_code}") + print(response.text) + transaction_id = json_parse["id"] + output_file.write(f"transaction_id={transaction_id}\n") + output_file.write(f"planned_start_time={datetime.datetime.fromtimestamp(start_time)}\n") + output_file.write(f"planned_end_time={datetime.datetime.fromtimestamp(end_time)}\n") + output_file.close() + else: + # PR is closed. Set actual start and end times on the CMR, close it, and send a Slack notification. + cmr_path = os.environ.get('CMR_PATH', '') + retrieved_transaction_id = os.environ.get('RETRIEVED_TRANSACTION_ID', '') + + if not retrieved_transaction_id: + print(f"No transaction ID found for path '{cmr_path}'. This CMR path was likely not created when the PR was opened. Skipping CMR closure.") + sys.exit(0) + + print(f"Closing CMR for path '{cmr_path}' with transaction ID: {retrieved_transaction_id}") + print("Waiting for Transaction from Queue to ServiceNow then Retrieve CMR ID...") + + print("Getting IMS Token") + headers = {"Content-Type":"multipart/form-data"} + data = { + 'client_id': os.environ['IMSACCESS_CLIENT_ID'], + 'client_secret': os.environ['IMSACCESS_CLIENT_SECRET'], + 'grant_type': "authorization_code", + 'code': os.environ['IMSACCESS_AUTH_CODE'] + } + response = requests.post(IMS_URL, data=data) + json_parse = json.loads(response.text) + + if response.status_code != 200: + print(f"{POST_FAILURE_MESSAGE} {response.status_code}") + print(response.text) + sys.exit(1) + elif find_string_in_json(json_parse, "error"): + print(f"IMS token request failed with response code: {response.status_code}") + print(response.text) + sys.exit(1) + else: + print(f"IMS token request was successful: {response.status_code}") + token = json_parse["access_token"] + + servicenow_get_cmr_url = f'{SERVICENOW_GET_CMR_URL}{os.environ["RETRIEVED_TRANSACTION_ID"]}' + headers = { + "Accept": APPLICATION_JSON, + "Authorization":token, + "api_key":os.environ['IPAAS_KEY'] + } + + # Wait 10 seconds to provide time for the transaction to exit the queue and be saved into ServiceNow as a CMR record. + time.sleep(10) + + try: + cmr_id = backoff_with_timeout(get_cmr_id_operation, max_retries=30, base_delay=1, max_delay=60, timeout=900) + print(f"CMR ID found and validated: {cmr_id}") + output_file.write(f"change_id={cmr_id}\n") + output_file.close() + except Exception as e: + print(f"All CMR ID retrieval attempts failed: {e}") + cmr_id = None + output_file.write(f"change_id={cmr_id}\n") + output_file.close() + + print("Setting Actual Maintenance Time Windows for CMR...") + actual_start_time = int((datetime.datetime.now() - datetime.timedelta(seconds = 10)).timestamp()) + actual_end_time = int(datetime.datetime.now().timestamp()) + + print("Closing CMR in ServiceNow...") + + close_notes = f"The change request is closed as the change was released successfully.\nPull Request Merged At: {os.environ['PR_MERGED_AT']}" + + headers = { + "Accept": APPLICATION_JSON, + "Authorization":token, + "Content-Type": APPLICATION_JSON, + "api_key":os.environ['IPAAS_KEY'] + } + data = { + "id": os.environ['RETRIEVED_TRANSACTION_ID'], + "actualStartDate": actual_start_time, + "actualEndDate": actual_end_time, + "state": "Closed", + "closeCode": "Successful", + "notes": close_notes + } + response = requests.post(SERVICENOW_CMR_URL, headers=headers, json=data) + json_parse = json.loads(response.text) + + if response.status_code != 200: + print(f"{POST_FAILURE_MESSAGE} {response.status_code}") + print(response.text) + sys.exit(1) + elif find_string_in_json(json_parse, "error"): + print(f"CMR closure failed with response code: {response.status_code}") + print(response.text) + sys.exit(1) + else: + print(f"CMR closure was successful: {response.status_code}") + print(response.text) + + print("Change Management Request has been closed.") + print(f"You can find the change record in ServiceNow https://adobe.service-now.com/now/change-launchpad/homepage, by searching for this ID: {cmr_id}") + print("") + print("If the CMR ID is not found, search for the change record in ServiceNow by the planned start time and/or planned end time found in the slack message sent by the workflow in the #wcms-generic-alerts channel.") + print("") + print(f"If all else fails, please check the ServiceNow queue for transaction ID '{os.environ['RETRIEVED_TRANSACTION_ID']}' and validate that the CMR was created successfully by reaching out to the Change Management team in the #unified-change-management-support slack channel.") diff --git a/.github/workflows/servicenow.yaml b/.github/workflows/servicenow.yaml new file mode 100644 index 000000000..d6d859531 --- /dev/null +++ b/.github/workflows/servicenow.yaml @@ -0,0 +1,185 @@ +# This workflow will install Python dependencies, run CMR creation in ServiceNow +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Create CMR in ServiceNow + +on: + pull_request: + types: + - opened + - closed + branches: + - MWPW-172706 + paths: + - 'io/**' + - 'studio/**' + +permissions: + pull-requests: write + contents: read + +# When switching between prod and stage, you must update four variables below: +# ENVIRONMENT, IMSACCESS_CLIENT_SECRET, IMSACCESS_AUTH_CODE and IPAAS_KEY +# The other variables are not environment-specific +env: + ENVIRONMENT: stage # Set to "stage" or "prod" + IMSACCESS_CLIENT_SECRET: ${{ secrets.IMSACCESS_CLIENT_SECRET_STAGE }} + IMSACCESS_AUTH_CODE: ${{ secrets.IMSACCESS_AUTH_CODE_STAGE }} + IPAAS_KEY: ${{ secrets.IPAAS_KEY_STAGE }} + # IMSACCESS_CLIENT_SECRET: ${{ secrets.IMSACCESS_CLIENT_SECRET_PROD }} + # IMSACCESS_AUTH_CODE: ${{ secrets.IMSACCESS_AUTH_CODE_PROD }} + # IPAAS_KEY: ${{ secrets.IPAAS_KEY_PROD }} + IMSACCESS_CLIENT_ID: ${{ secrets.IMSACCESS_CLIENT_ID }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.MAS_RELEASE_SLACK_WH }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_CREATED_AT: ${{ github.event.pull_request.created_at }} + PR_MERGED_AT: ${{ github.event.pull_request.merged_at }} + PR_LINK: ${{ github.event.pull_request.html_url }} + PR_STATE: ${{ github.event.pull_request.state }} + SNOW_INSTANCE_ID_IO: ${{ secrets.SNOW_INSTANCE_ID_IO }} + SNOW_INSTANCE_ID_STUDIO: ${{ secrets.SNOW_INSTANCE_ID_STUDIO }} + +jobs: + # Detect which paths have changed in the PR + detect-changes: + if: ${{ github.event.pull_request.state == 'open' }} + runs-on: ubuntu-latest + outputs: + changed_path: ${{ steps['set-instance-id'].outputs.changed_path }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 # Fetch all history to ensure we can compare commits + + - name: Detect changed paths and set SNOW Instance ID + id: set-instance-id + run: | + # Get the list of changed files in the PR + CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }}) + + echo "Changed files:" + echo "$CHANGED_FILES" + + # Check if any files match the io/** pattern + IO_CHANGED=false + if echo "$CHANGED_FILES" | grep -q "^io/"; then + IO_CHANGED=true + echo "Changes detected in io/** path" + fi + + # Check if any files match the studio/** pattern + STUDIO_CHANGED=false + if echo "$CHANGED_FILES" | grep -q "^studio/"; then + STUDIO_CHANGED=true + echo "Changes detected in studio/** path" + fi + + # Set SNOW Instance ID based on which paths changed + if [[ "$IO_CHANGED" == "true" && "$STUDIO_CHANGED" == "true" ]]; then + echo "changed_path=both" >> $GITHUB_OUTPUT + echo "Setting changed_path to both (IO and Studio)" + elif [[ "$IO_CHANGED" == "true" ]]; then + echo "changed_path=io" >> $GITHUB_OUTPUT + echo "Setting changed_path to io" + elif [[ "$STUDIO_CHANGED" == "true" ]]; then + echo "changed_path=studio" >> $GITHUB_OUTPUT + echo "Setting changed_path to studio" + else + echo "::error::ERROR: Workflow was triggered but no changes detected in io/ or studio/ paths. This should not happen given the path filters in the workflow trigger." + exit 1 + fi + + # matrix.* is not available in job-level `if`. Use step `cmr_row` once, then + # `steps.cmr_row.outcome == 'success'` on steps that should run for this row. + create-cmr: + needs: detect-changes + strategy: + matrix: + path: [io, studio] + if: ${{ always() && ((github.event.pull_request.state == 'open' && needs.detect-changes.result == 'success') || github.event.pull_request.merged == true) }} + runs-on: ubuntu-latest + + steps: + - name: Matrix row active (merged PR, or open PR and path matches) + id: cmr_row + if: >- + ${{ github.event.pull_request.merged == true + || (github.event.pull_request.state == 'open' + && (needs.detect-changes.outputs.changed_path == matrix.path + || needs.detect-changes.outputs.changed_path == 'both')) }} + run: echo "Running CMR steps for matrix.path=${{ matrix.path }}" + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + if: steps.cmr_row.outcome == 'success' + - name: Set up Python 3.x, latest minor release + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 + if: steps.cmr_row.outcome == 'success' + with: + python-version: "3.x" + - name: Install dependencies + if: steps.cmr_row.outcome == 'success' + run: | + python -m pip install --upgrade pip requests timedelta + + - name: Retrieve transaction ID from PR Comments (${{ matrix.path }}) + id: retrieve-transactionId-step + if: steps.cmr_row.outcome == 'success' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea + with: + script: | + const main = (await import('${{ github.workspace }}/.github/workflows/snow-pr-comment.js')).default + if (process.env.PR_STATE == 'closed') { + main({ github, context, transaction_id: null, path_identifier: '${{ matrix.path }}' }) + } else { + console.log('PR is not in an closed state. Skipping...'); + } + + - name: Execute script for creating and closing CMR (${{ matrix.path }}) + id: create-close-cmr-step + if: steps.cmr_row.outcome == 'success' + env: + RETRIEVED_TRANSACTION_ID: ${{ steps['retrieve-transactionId-step'].outputs.retrieved_transaction_id }} + SNOW_INSTANCE_ID: ${{ github.event.pull_request.state == 'open' && (matrix.path == 'io' && secrets.SNOW_INSTANCE_ID_IO || secrets.SNOW_INSTANCE_ID_STUDIO) || '' }} + CMR_PATH: ${{ matrix.path }} + run: | + python ./.github/workflows/servicenow.py + + - name: Save transaction ID in PR Comments (${{ matrix.path }}) + id: pr-comment-snow-cr-step + if: steps.cmr_row.outcome == 'success' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea + env: + TRANSACTION_ID: ${{ steps['create-close-cmr-step'].outputs.transaction_id }} + with: + script: | + const main = (await import('${{ github.workspace }}/.github/workflows/snow-pr-comment.js')).default + if (process.env.PR_STATE == 'open') { + main({ github, context, path_identifier: '${{ matrix.path }}' }) + } else { + console.log('PR is not in an opened state. Skipping...'); + } + + - name: Execute script for notifying Slack of CMR state (${{ matrix.path }}) + id: slack-snow-cr-step + if: >- + ${{ steps.cmr_row.outcome == 'success' + && ((github.event.pull_request.state == 'open' && steps['create-close-cmr-step'].outputs.transaction_id) + || (github.event.pull_request.state == 'closed' && steps['retrieve-transactionId-step'].outputs.retrieved_transaction_id)) }} + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea + env: + RETRIEVED_TRANSACTION_ID: ${{ steps['retrieve-transactionId-step'].outputs.retrieved_transaction_id }} + TRANSACTION_ID: ${{ steps['create-close-cmr-step'].outputs.transaction_id }} + CHANGE_ID: ${{ steps['create-close-cmr-step'].outputs.change_id }} + PLANNED_START_TIME: ${{ steps['create-close-cmr-step'].outputs.planned_start_time }} + PLANNED_END_TIME: ${{ steps['create-close-cmr-step'].outputs.planned_end_time }} + with: + script: | + const main = (await import('${{ github.workspace }}/.github/workflows/snow-cr-notification.js')).default; + if (process.env.PR_STATE == 'open') { + await main({ github, context, path_identifier: '${{ matrix.path }}' }); + } else { + await main({ github, context, transaction_id: process.env.RETRIEVED_TRANSACTION_ID, path_identifier: '${{ matrix.path }}' }); + } diff --git a/.github/workflows/snow-cr-notification.js b/.github/workflows/snow-cr-notification.js new file mode 100644 index 000000000..9fb8a92e4 --- /dev/null +++ b/.github/workflows/snow-cr-notification.js @@ -0,0 +1,39 @@ +import { slackNotification, getLocalConfigs } from './helpers.js'; + +async function main({ + github = getLocalConfigs().github, + context = getLocalConfigs().context, + transaction_id = process.env.TRANSACTION_ID, + change_id = process.env.CHANGE_ID, + planned_start_time = process.env.PLANNED_START_TIME, + planned_end_time = process.env.PLANNED_END_TIME, + path_identifier = '' +} = {}) { + if (!github || !context) { + throw new Error("GitHub context is missing. Ensure you are running in the correct environment."); + } + + const { pull_request } = context.payload; + if (!pull_request) { + console.log("No pull_request found in context payload. Skipping notification."); + return; + } + + const { number, title, html_url } = pull_request; + const isOpen = process.env.PR_STATE === 'open'; + const environment = process.env.ENVIRONMENT || 'stage'; + const envLabel = environment === 'stage' ? ' in the QA ServiceNow environment' : ''; + const pathLabel = path_identifier ? ` [${path_identifier.toUpperCase()}]` : ''; + const prefix = isOpen + ? ':servicenow_logo: ServiceNow Change Request Created and in progress' + envLabel + pathLabel + ': Transaction ID: ' + transaction_id + '\n: Planned start: ' + planned_start_time + ' | end: ' + planned_end_time + ' | ' + : ':servicenow_logo: ServiceNow Change Request Closed' + envLabel + pathLabel + ': Search for Change Record by Change ID: ' + change_id + ' or search for it by planned start, end time\n:'; + + console.log(`Sending SNOW CR notification for PR #${number}: ${title}${pathLabel}`); + + await slackNotification( + `${prefix} <${html_url}|#${number}>.`, + process.env.SLACK_WEBHOOK_URL + ); +} + +export default main; diff --git a/.github/workflows/snow-pr-comment.js b/.github/workflows/snow-pr-comment.js new file mode 100644 index 000000000..70f947f40 --- /dev/null +++ b/.github/workflows/snow-pr-comment.js @@ -0,0 +1,73 @@ +// Run from the root of the project for local testing: node --env-file=.env .github/workflows/pr-reminders.js +import { getLocalConfigs } from './helpers.js'; +import fs from 'fs'; +const SNOW_TRANSACTION_ID_COMMENT = "SNOW Change Request Transaction ID"; + +const main = async ({ github = getLocalConfigs().github, context = getLocalConfigs().context, transaction_id = process.env.TRANSACTION_ID, path_identifier = '' }) => { + const comment = async ({ pr_number, message, comments }) => { + if (comments.some((c) => c.body.includes(message))) { + console.log( + `SNOW Transaction Comment exists. Commenting skipped... ${message}` + ); + return; + } + process.env.LOCAL_RUN + ? console.log( + `PR #${pr_number} Local execution commenting SKIPPED message for SNOW Change Request: "${message}"` + ) + : await github.rest.issues + .createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr_number, + body: message, + token: process.env.GITHUB_TOKEN, + }) + .then(() => console.log(`PR #${pr_number} Commented for SNOW Change Request: "${message}"`)) + .catch(console.error); + }; + + try { + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.PR_NUMBER, + }); + + const pathLabel = path_identifier ? ` [${path_identifier.toUpperCase()}]` : ''; + const commentIdentifier = `${SNOW_TRANSACTION_ID_COMMENT}${pathLabel}`; + + if (process.env.PR_STATE !== 'open') { + // find transactionId in a pure way + const transactionIdComment = comments.find(singleComment => singleComment.body.includes(commentIdentifier)); + + // Run the effect + if (transactionIdComment === undefined) { + console.log(`No SNOW Transaction ID Comment found for ${path_identifier || 'default path'}. Skipping...`); + } else { + console.log(`Found SNOW Transaction ID Comment for ${path_identifier || 'default path'}. Assigning transaction ID for closing SNOW Change Request...`); + const transactionID = transactionIdComment.body.split(`${commentIdentifier}: `)?.[1].trim(); + console.log(`Found Transaction ID: ${transactionID}`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `retrieved_transaction_id=${transactionID}\n`); + } + + return; + } + else if (transaction_id && transaction_id !== 'null') { + comment({ + pr_number: process.env.PR_NUMBER, + comments, + message: + `${commentIdentifier}: ` + transaction_id, + }); + } + else { + console.log(`No SNOW Transaction ID found for ${path_identifier || 'default path'}. Can't make PR comment. Skipping...`); + return; + } + } catch (error) { + console.error(error); + } +}; + +export default main; diff --git a/.github/workflows/studio-monitor.yml b/.github/workflows/studio-monitor.yml-temp-disabled similarity index 100% rename from .github/workflows/studio-monitor.yml rename to .github/workflows/studio-monitor.yml-temp-disabled diff --git a/.github/workflows/web-components-pr.yaml b/.github/workflows/web-components-pr.yaml-temp-disabled similarity index 100% rename from .github/workflows/web-components-pr.yaml rename to .github/workflows/web-components-pr.yaml-temp-disabled diff --git a/io/studio/README.md b/io/studio/README.md index 104f4a160..efa19ea3e 100644 --- a/io/studio/README.md +++ b/io/studio/README.md @@ -117,3 +117,4 @@ You can generate this file using the command `aio app use`. Be aware that it wou ### `app.config.yaml` Main configuration file that defines an application's implementation. + diff --git a/nala/studio/placeholders/tests/placeholders.test.js b/nala/studio/placeholders/tests/placeholders.test.js index aebdaac44..3d2fad5cf 100644 --- a/nala/studio/placeholders/tests/placeholders.test.js +++ b/nala/studio/placeholders/tests/placeholders.test.js @@ -123,3 +123,5 @@ test.describe('M@S Studio Placeholders Test Suite', () => { }); }); }); + + diff --git a/web-components/README.md b/web-components/README.md index 89405b1fe..21a1abcbd 100644 --- a/web-components/README.md +++ b/web-components/README.md @@ -89,3 +89,4 @@ npm run lint ## Troubleshooting Reach out in the `#merch-at-scale` Slack channel for support. +