Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
6bb7f6e
MWPW-172706 - create a PR for each PR to the main branch. First comm…
Nov 25, 2025
7194936
Merge branch 'main' into MWPW-172706
Nov 26, 2025
18c907f
MWPW-172706 - rewrite path logic to remove dependency on external action
Nov 26, 2025
f2e2c27
Merge branch 'main' into MWPW-172706
Dec 3, 2025
427b88d
MWPW-172706 - remove permissions from the job step
Dec 3, 2025
1382821
MWPW-172706 - refactor new workflow to use ES module syntax, and temp…
ralphSF Dec 12, 2025
be3a360
MWPW-172706 - test workflow by making changes to both watched paths (…
ralphSF Dec 12, 2025
5a142f3
MWPW-172706 - test workflow by making changes to both watched paths (…
ralphSF Dec 12, 2025
4e6ccd6
MWPW-172706 - switch from pull_request_target to pull_request
Dec 12, 2025
264da7d
MWPW-172706 - test workflow by making changes to both watched paths (…
ralphSF Dec 12, 2025
601af3b
MWPW-172706 - update servicenow stage URLs
Dec 12, 2025
75824e2
MWPW-172706 - update workflow to pass the serviceIDs correctly
Dec 12, 2025
67e2d9c
MWPW-172706 - rework syntax since github didn't like the last one.
Dec 12, 2025
6430050
MWPW-172706 - test workflow by making changes to both watched paths (…
ralphSF Dec 12, 2025
17f50ca
MWPW-172706 - only run detect-changes step on PR open
Dec 12, 2025
27cd749
MWPW-172706 - test workflow by making changes to both watched paths (…
ralphSF Dec 12, 2025
23700db
MWPW-172706 - switch to temp service IDs for testing
Dec 15, 2025
4338a5f
MWPW-172706 - another update to switch to temp service IDs for testing
Dec 15, 2025
0e65006
MWPW-172706 - test workflow by making changes to both watched paths (…
ralphSF Dec 15, 2025
a6aa70a
MWPW-172706 - add ENVIRONMENT variable to easily assign correct IMS a…
Dec 15, 2025
151aa6a
MWPW-172706 - update the slack webhook variable to be more generic
Dec 15, 2025
d9f39f6
MWPW-172706 - test workflow by making changes to both watched paths (…
ralphSF Dec 15, 2025
0d61666
MWPW-172706 - update the CMR test results message
Dec 15, 2025
2b61bb9
MWPW-172706 - swtich back to test Slack channel for notifications
Dec 16, 2025
266e320
MWPW-172706 - test workflow by making changes to only one watched pat…
ralphSF Dec 16, 2025
b540c3b
Merge branch 'MWPW-172706' of https://github.com/adobecom/mas into MW…
Dec 16, 2025
1715507
MWPW-172706 - test workflow by making changes to only one watched pat…
ralphSF Dec 16, 2025
fb03239
MWPW-172706 - test workflow by making changes to an unwatched path (#…
ralphSF Dec 16, 2025
8a19965
MWPW-172706 - switch to prod values for service registry IDs, IMS and…
Dec 16, 2025
8201880
MWPW-172706 - switch to prod secrets
Dec 16, 2025
4b0a6f8
Merge branch 'main' into MWPW-172706
Dec 17, 2025
8d13dce
MWPW-172706 - switch to back to stage secrets for additional stage te…
Dec 17, 2025
625c38d
MWPW-172706 - major refactor. Create 1 or 2 separate CMRs depending …
Dec 17, 2025
3965eec
MWPW-172706 - test prod CMR with one watched path, io (#489)
ralphSF Dec 17, 2025
20b205d
MWPW-172706 - more refactoring, for PR closure
Dec 17, 2025
11659a7
MWPW-172706 - test prod CMR with one watched path, io (#490)
ralphSF Dec 17, 2025
2beface
MWPW-172706 - more refactoring, for Slack notifications
Dec 17, 2025
050e1c3
MWPW-172706 - test prod CMR with one watched path, studio (#491)
ralphSF Dec 17, 2025
a94c07f
MWPW-172706 - test prod CMR with both watched paths (#492)
ralphSF Dec 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/helpers.js
Original file line number Diff line number Diff line change
@@ -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 };

318 changes: 318 additions & 0 deletions .github/workflows/servicenow.py
Original file line number Diff line number Diff line change
@@ -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.")
Loading