From 842720a8c0eae06fc73a637cbcf5626d540821c6 Mon Sep 17 00:00:00 2001 From: Aviral Date: Wed, 17 Jun 2026 21:28:18 +0530 Subject: [PATCH 1/5] chore: upgrade Java Maven stack to Java 25 --- .hackerrank/merge_surefire_to_unit_xml.py | 76 +++++++++++++++++++++++ .hackerrank/run-tests-with-unit-xml.sh | 15 +++++ hackerrank.yml | 5 +- pom.xml | 24 ++++--- 4 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 .hackerrank/merge_surefire_to_unit_xml.py create mode 100755 .hackerrank/run-tests-with-unit-xml.sh diff --git a/.hackerrank/merge_surefire_to_unit_xml.py b/.hackerrank/merge_surefire_to_unit_xml.py new file mode 100644 index 0000000..7ec5806 --- /dev/null +++ b/.hackerrank/merge_surefire_to_unit_xml.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import glob +import shutil +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def as_int(value: str | None) -> int: + try: + return int(value or "0") + except ValueError: + return 0 + + +def as_float(value: str | None) -> float: + try: + return float(value or "0") + except ValueError: + return 0.0 + + +def empty_report(message: str) -> ET.ElementTree: + suites = ET.Element("testsuites", {"name": "maven", "tests": "1", "failures": "0", "errors": "1", "skipped": "0"}) + suite = ET.SubElement(suites, "testsuite", {"name": "report-generation", "tests": "1", "failures": "0", "errors": "1", "skipped": "0"}) + case = ET.SubElement(suite, "testcase", {"classname": "report-generation", "name": "surefire-report"}) + ET.SubElement(case, "error", {"message": message}).text = message + return ET.ElementTree(suites) + + +def main() -> int: + output = Path("unit.xml") + reports = sorted(Path(".").glob("target/surefire-reports/TEST-*.xml")) + reports += sorted(Path(".").glob("target/failsafe-reports/TEST-*.xml")) + valid = [] + for report in reports: + try: + tree = ET.parse(report) + except ET.ParseError: + continue + root = tree.getroot() + if root.tag in {"testsuite", "testsuites"}: + valid.append((report, root)) + + if not valid: + tree = empty_report("No Maven Surefire/Failsafe XML reports were generated.") + tree.write(output, encoding="utf-8", xml_declaration=True) + return 1 + + if len(valid) == 1 and valid[0][1].tag == "testsuite": + shutil.copyfile(valid[0][0], output) + return 0 + + suites = ET.Element("testsuites", {"name": "maven"}) + totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} + total_time = 0.0 + for _, root in valid: + children = [root] if root.tag == "testsuite" else list(root.findall("testsuite")) + for suite in children: + totals["tests"] += as_int(suite.get("tests")) + totals["failures"] += as_int(suite.get("failures")) + totals["errors"] += as_int(suite.get("errors")) + totals["skipped"] += as_int(suite.get("skipped")) + total_time += as_float(suite.get("time")) + suites.append(suite) + for key, value in totals.items(): + suites.set(key, str(value)) + suites.set("time", f"{total_time:.3f}") + ET.ElementTree(suites).write(output, encoding="utf-8", xml_declaration=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.hackerrank/run-tests-with-unit-xml.sh b/.hackerrank/run-tests-with-unit-xml.sh new file mode 100755 index 0000000..a5a7e39 --- /dev/null +++ b/.hackerrank/run-tests-with-unit-xml.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set +e +if [ "$#" -eq 0 ]; then + echo "Usage: $0 ''" >&2 + exit 2 +fi +rm -f unit.xml +bash -lc "$*" +status=$? +python3 .hackerrank/merge_surefire_to_unit_xml.py +merge_status=$? +if [ "$status" -eq 0 ] && [ "$merge_status" -ne 0 ]; then + exit "$merge_status" +fi +exit "$status" diff --git a/hackerrank.yml b/hackerrank.yml index 99c1daa..8f05abb 100644 --- a/hackerrank.yml +++ b/hackerrank.yml @@ -6,8 +6,9 @@ configuration: - src/test/* - generate-data.sh scoring: - command: mvn clean test -Dsurefire.reportFormat=xml; bash merge_test_reports.sh + command: bash .hackerrank/run-tests-with-unit-xml.sh 'mvn clean test -Dsurefire.reportFormat=xml; bash merge_test_reports.sh' files: + - unit.xml - reports/test_result.xml hidden_files_paths: - src/test/java/com/hackerrank/risk/PerformanceTest.java @@ -21,4 +22,4 @@ configuration: project_menu: run: mvn clean package -DskipTests && java -jar target/risk-engine-1.0.jar < sample-transactions.csv install: mvn clean install -DskipTests - test: mvn clean test -Dsurefire.reportFormat=xml; bash merge_test_reports.sh + test: bash .hackerrank/run-tests-with-unit-xml.sh 'mvn clean test -Dsurefire.reportFormat=xml; bash merge_test_reports.sh' diff --git a/pom.xml b/pom.xml index b4c2e8f..3472070 100644 --- a/pom.xml +++ b/pom.xml @@ -1,7 +1,5 @@ - - + + 4.0.0 com.hackerrank @@ -14,17 +12,17 @@ UTF-8 - 21 - 21 + 25 + 25 5.9.3 - + 2525 org.junit.jupiter junit-jupiter - ${junit.version} + 6.0.1 test @@ -35,31 +33,31 @@ org.apache.maven.plugins maven-compiler-plugin - 3.11.0 + 3.15.0 21 21 - + 25 org.apache.maven.plugins maven-surefire-plugin - 3.0.0 + 3.5.6 **/*Test.java xml - + ${project.build.directory}/surefire-reportsfalse org.apache.maven.plugins maven-jar-plugin - 3.3.0 + 3.4.2 From cbd053a1bd43eb8cd7b9849dac5939a99387229b Mon Sep 17 00:00:00 2001 From: Aviral Date: Wed, 17 Jun 2026 22:18:50 +0530 Subject: [PATCH 2/5] docs: add Java 25 README contract --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..78b4fec --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# Adyen Risk Engine + +This Java Maven challenge is upgraded for Java 25. + +## Validation + +- Build and tests run with Maven on Java 25. +- HackerRank scoring generates `unit.xml` from Maven Surefire/Failsafe XML output through `.hackerrank/run-tests-with-unit-xml.sh`. +- Candidate-facing source, tests, and scoring entry points remain in the existing project layout. From 66c540ce9a14527401abf74c222c1e31451bf443 Mon Sep 17 00:00:00 2001 From: Aviral Date: Thu, 18 Jun 2026 01:39:57 +0530 Subject: [PATCH 3/5] fix: use surefire reports directly for scoring --- .hackerrank/merge_surefire_to_unit_xml.py | 76 ----------------------- .hackerrank/run-tests-with-unit-xml.sh | 15 ----- README.md | 1 - hackerrank.yml | 5 +- 4 files changed, 2 insertions(+), 95 deletions(-) delete mode 100644 .hackerrank/merge_surefire_to_unit_xml.py delete mode 100755 .hackerrank/run-tests-with-unit-xml.sh diff --git a/.hackerrank/merge_surefire_to_unit_xml.py b/.hackerrank/merge_surefire_to_unit_xml.py deleted file mode 100644 index 7ec5806..0000000 --- a/.hackerrank/merge_surefire_to_unit_xml.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import glob -import shutil -import sys -import xml.etree.ElementTree as ET -from pathlib import Path - - -def as_int(value: str | None) -> int: - try: - return int(value or "0") - except ValueError: - return 0 - - -def as_float(value: str | None) -> float: - try: - return float(value or "0") - except ValueError: - return 0.0 - - -def empty_report(message: str) -> ET.ElementTree: - suites = ET.Element("testsuites", {"name": "maven", "tests": "1", "failures": "0", "errors": "1", "skipped": "0"}) - suite = ET.SubElement(suites, "testsuite", {"name": "report-generation", "tests": "1", "failures": "0", "errors": "1", "skipped": "0"}) - case = ET.SubElement(suite, "testcase", {"classname": "report-generation", "name": "surefire-report"}) - ET.SubElement(case, "error", {"message": message}).text = message - return ET.ElementTree(suites) - - -def main() -> int: - output = Path("unit.xml") - reports = sorted(Path(".").glob("target/surefire-reports/TEST-*.xml")) - reports += sorted(Path(".").glob("target/failsafe-reports/TEST-*.xml")) - valid = [] - for report in reports: - try: - tree = ET.parse(report) - except ET.ParseError: - continue - root = tree.getroot() - if root.tag in {"testsuite", "testsuites"}: - valid.append((report, root)) - - if not valid: - tree = empty_report("No Maven Surefire/Failsafe XML reports were generated.") - tree.write(output, encoding="utf-8", xml_declaration=True) - return 1 - - if len(valid) == 1 and valid[0][1].tag == "testsuite": - shutil.copyfile(valid[0][0], output) - return 0 - - suites = ET.Element("testsuites", {"name": "maven"}) - totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} - total_time = 0.0 - for _, root in valid: - children = [root] if root.tag == "testsuite" else list(root.findall("testsuite")) - for suite in children: - totals["tests"] += as_int(suite.get("tests")) - totals["failures"] += as_int(suite.get("failures")) - totals["errors"] += as_int(suite.get("errors")) - totals["skipped"] += as_int(suite.get("skipped")) - total_time += as_float(suite.get("time")) - suites.append(suite) - for key, value in totals.items(): - suites.set(key, str(value)) - suites.set("time", f"{total_time:.3f}") - ET.ElementTree(suites).write(output, encoding="utf-8", xml_declaration=True) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.hackerrank/run-tests-with-unit-xml.sh b/.hackerrank/run-tests-with-unit-xml.sh deleted file mode 100755 index a5a7e39..0000000 --- a/.hackerrank/run-tests-with-unit-xml.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set +e -if [ "$#" -eq 0 ]; then - echo "Usage: $0 ''" >&2 - exit 2 -fi -rm -f unit.xml -bash -lc "$*" -status=$? -python3 .hackerrank/merge_surefire_to_unit_xml.py -merge_status=$? -if [ "$status" -eq 0 ] && [ "$merge_status" -ne 0 ]; then - exit "$merge_status" -fi -exit "$status" diff --git a/README.md b/README.md index 78b4fec..960df97 100644 --- a/README.md +++ b/README.md @@ -5,5 +5,4 @@ This Java Maven challenge is upgraded for Java 25. ## Validation - Build and tests run with Maven on Java 25. -- HackerRank scoring generates `unit.xml` from Maven Surefire/Failsafe XML output through `.hackerrank/run-tests-with-unit-xml.sh`. - Candidate-facing source, tests, and scoring entry points remain in the existing project layout. diff --git a/hackerrank.yml b/hackerrank.yml index 8f05abb..99c1daa 100644 --- a/hackerrank.yml +++ b/hackerrank.yml @@ -6,9 +6,8 @@ configuration: - src/test/* - generate-data.sh scoring: - command: bash .hackerrank/run-tests-with-unit-xml.sh 'mvn clean test -Dsurefire.reportFormat=xml; bash merge_test_reports.sh' + command: mvn clean test -Dsurefire.reportFormat=xml; bash merge_test_reports.sh files: - - unit.xml - reports/test_result.xml hidden_files_paths: - src/test/java/com/hackerrank/risk/PerformanceTest.java @@ -22,4 +21,4 @@ configuration: project_menu: run: mvn clean package -DskipTests && java -jar target/risk-engine-1.0.jar < sample-transactions.csv install: mvn clean install -DskipTests - test: bash .hackerrank/run-tests-with-unit-xml.sh 'mvn clean test -Dsurefire.reportFormat=xml; bash merge_test_reports.sh' + test: mvn clean test -Dsurefire.reportFormat=xml; bash merge_test_reports.sh From 6222e3f25e3f774684157a7c0b6e2d3e9cf1bb8a Mon Sep 17 00:00:00 2001 From: Aviral Date: Sun, 5 Jul 2026 20:59:59 +0530 Subject: [PATCH 4/5] Address Maven review comments --- pom.xml | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/pom.xml b/pom.xml index 3472070..1fd5652 100644 --- a/pom.xml +++ b/pom.xml @@ -1,22 +1,18 @@ 4.0.0 - com.hackerrank risk-engine 1.0 jar - Transaction Risk Engine Real-time transaction risk evaluation engine - UTF-8 - 25 - 25 5.9.3 - 2525 - + 25 + 25 + @@ -26,7 +22,6 @@ test - @@ -35,11 +30,9 @@ maven-compiler-plugin 3.15.0 - 21 - 21 - 25 + 25 + - org.apache.maven.plugins @@ -50,9 +43,13 @@ **/*Test.java xml - ${project.build.directory}/surefire-reportsfalse + ${project.build.directory}/surefire-reports + false + classes + 4 + true + - org.apache.maven.plugins @@ -66,7 +63,6 @@ - From 4a49d8c8aaa919005b5e604a8074bf343705b1d3 Mon Sep 17 00:00:00 2001 From: Aviral Date: Tue, 7 Jul 2026 12:58:09 +0530 Subject: [PATCH 5/5] chore: sync validation workflows --- .github/scripts/requirements.txt | 1 + .github/scripts/validate.py | 292 +++++++++++++++++ .github/scripts/validate_devops.py | 304 ++++++++++++++++++ .../workflows/validate_devops_workflow.yml | 59 ++++ .github/workflows/validate_workflow.yml | 120 +++++++ 5 files changed, 776 insertions(+) create mode 100644 .github/scripts/requirements.txt create mode 100644 .github/scripts/validate.py create mode 100644 .github/scripts/validate_devops.py create mode 100644 .github/workflows/validate_devops_workflow.yml create mode 100644 .github/workflows/validate_workflow.yml diff --git a/.github/scripts/requirements.txt b/.github/scripts/requirements.txt new file mode 100644 index 0000000..077c95d --- /dev/null +++ b/.github/scripts/requirements.txt @@ -0,0 +1 @@ +requests==2.31.0 \ No newline at end of file diff --git a/.github/scripts/validate.py b/.github/scripts/validate.py new file mode 100644 index 0000000..d214f67 --- /dev/null +++ b/.github/scripts/validate.py @@ -0,0 +1,292 @@ +import requests +import json +import sys +from http.cookies import SimpleCookie +import time +import os +import logging + +logging.basicConfig(level=logging.DEBUG) + +ENV_STACK_MAPPING = { + 'java21_0__maven': '58', + 'java21_0__gradle': '59', + 'java21_0__springboot': '60', + 'kotlin2_1_android13114758': '79', + 'node_22_mongo_8__mern': '78', + 'node_22_mongo_8__mean': '77', + 'java21_0_android13114758': '76', + 'cpp14': '75', + 'java21_0__cucumber': '74', + 'ruby3_4__rails': '73', + 'spark3_5': '72', + 'pyspark3_5': '71', + 'python3_12__django': '70', + 'php8_4__symfony': '69', + 'php8_4__laravel': '68', + 'php8_4__codeigniter': '67', + 'php8_4': '66', + 'dotnet8_0': '65', + 'nodejs22_16__vuejs': '64', + 'nodejs22_16': '63', + 'nodejs22_16__angularjs': '62', + 'nodejs22_16__reactjs': '61', + 'go1_23': '52', + 'jam__java_21__mongo8': '80', + 'dam__python3_12__mongo8': '81', + 'expo__node_22__react_native': '82', + 'flutter_web__node22': '83', + 'dam__python3_13__mongo8': '102', + 'python3_13__django': '93', + 'go1_26': '94', + 'python3_13__flask': '95', + 'python3_13__flask__psql': '96', + 'go1_26__mysql8': '98', + 'go1_26__mongo8': '100', + 'nodejs24': '97', + 'nodejs24__reactjs': '105', + 'nodejs24__angularjs': '106', + 'nodejs24__vuejs': '107', + 'java25_0__springboot': '108', + 'jam__springboot__mongo8': '109', + 'java25_0__maven': '111', +} + +STACK_ENV_MAPPING = { v: k for k, v in ENV_STACK_MAPPING.items() } + +STACKS = json.loads(os.environ['HACKERRANK_STACKS']) +TARGET_STACK = os.environ['TARGET_STACK'] +TARGET_STACKS = set(STACKS.values()) +SOLUTION_VALIDATION = False + +SESSION = requests.session() +SESSION.headers.update({ + "Authorization": f"Bearer {os.environ['HACKERRANK_TOKEN']}", + "cache-control": "no-cache", + "content-type": "application/json", +}) + +BASE_URL = "https://www.hackerrank.com/" + + +def question_exists(qid): + print('finding question in company library...') + + tag_to_search = f"{qid}-{TARGET_STACK}-solution" if SOLUTION_VALIDATION else f"{qid}-{TARGET_STACK}" + + library_url = BASE_URL + f"x/api/v1/library?limit=1&library=personal_all&tags={tag_to_search},duplicate" + response = SESSION.request("GET", library_url) + + if response.status_code != 200: + raise Exception("Failed to find question") + + data = response.json() + + if len(data['model']['questions']) != 0: + return True, data['model']['questions'][0] + + return False, None + + +def clone_question(qid): + print('cloning question...') + + clone_url = BASE_URL + "x/api/v1/questions/clone" + tag_to_search = f"{qid}-{TARGET_STACK}-solution" if SOLUTION_VALIDATION else f"{qid}-{TARGET_STACK}" + payload = json.dumps({ + "id": qid, + "name": f"Copy of {tag_to_search}" + }) + response = SESSION.request("POST", clone_url, data=payload) + + if response.status_code != 200: + raise Exception("Failed to clone question") + + data = response.json() + + return data['question'] + + +def get_target_subtype(question): + if TARGET_STACK != 'based_on_current_stack': + return TARGET_STACK + + environment_id = question['environment_id'] + sub_type = question['sub_type'] + if environment_id: + sub_type = STACK_ENV_MAPPING.get(str(environment_id)) + + if not STACKS.get(sub_type): + raise Exception("Target sub type not found") + + return STACKS[sub_type] + + +def update_question(question, qid): + target_sub_type = get_target_subtype(question) + + print(f"update question...{question['id']}..cloned..{target_sub_type}") + + original_tags = question['visible_tags_array'] + tag_to_add = f"{qid}-{TARGET_STACK}-solution" if SOLUTION_VALIDATION else f"{qid}-{TARGET_STACK}" + + if question.get("sub_type") == "custom_vm" and ".NET" in original_tags: + question["sub_type"] = "dotnet3" + + if tag_to_add not in original_tags: + original_tags.extend([tag_to_add, "duplicate"]) + + + update_url = BASE_URL + f"x/api/v1/questions/{question['id']}" + + payload = { + "visible_tags_array": original_tags, + } + if target_sub_type in ENV_STACK_MAPPING: + payload['environment_id'] = int(ENV_STACK_MAPPING[target_sub_type]) + payload['sub_type'] = None + else: + payload['sub_type'] = target_sub_type + payload['environment_id'] = None + + print(f"payload {payload}") + response = SESSION.request("PUT", update_url, data=json.dumps(payload)) + + if not question_updated(response, payload): + print("Cloned question still have old subtype, retrying") + response = SESSION.request("PUT", update_url, data=json.dumps(payload)) + + if not question_updated(response, payload): + raise Exception("Cloned question still have old subtype") + + +def question_updated(response, payload): + if response.status_code != 200: + raise Exception("Failed to update cloned question") + + data = response.json() + + sub_type_environment_id = data['model'].get('environment_id', None) or data['model'].get('sub_type', None) + print(f"Cloned question subtype {sub_type_environment_id}") + + if payload['sub_type'] is not None: + return sub_type_environment_id in TARGET_STACKS + elif payload['environment_id'] is not None: + return str(sub_type_environment_id) in STACK_ENV_MAPPING + else: + raise Exception("No sub type or environment id found") + + +def update_project_zip(question): + print('updating project zip...') + update_url = BASE_URL + f"x/api/v1/questions/{question['id']}/upload" + files = [ + ('source_file', ('project.zip', open('project.zip', 'rb'), 'application/zip')) + ] + del SESSION.headers['content-type'] + response = SESSION.post(update_url, files=files, data={'a': 1}) + + if response.status_code != 200: + print(response.status_code) + raise Exception("Failed to update project zip") + + SESSION.headers.update({'content-type': 'application/json'}) + + +def validate_question(question): + print('validating question...') + validate_url = BASE_URL + f"x/api/v1/questions/{question['id']}/validate_fullstack" + + response = SESSION.post(validate_url) + + if response.status_code != 200: + print('Error in validate_question', response.status_code, response.text) + raise Exception("Failed to start validation...") + + return response.json()['task_id'] + + +def check_validation_status(task_id): + print('polling on validation task with id', task_id) + poll_url = BASE_URL + f"x/api/v1/delayed_tasks/{task_id}/poll" + + while True: + time.sleep(5) + response = SESSION.get(poll_url) + + if response.status_code != 200: + raise Exception("Failed to get validation status", response.status_code, response.text) + + data = response.json() + if data['status_code'] == 2 or data['response']['additional_data']['valid'] == True: + print(data) + return data + + +def process_validator_response(validator_response): + print('processing validator response...') + if not validator_response['valid']: + for step, value in validator_response['data'].items(): + if value['valid'] == False: + print(step, value) + raise Exception(value['error']) + + print("validated successfully") + + scoring_output = validator_response['data']['validate_scoring_output'] + + if SOLUTION_VALIDATION and not scoring_output['details']: + raise Exception('scoring output details is empty') + + if scoring_output['details']: + score = scoring_output['details']['scoring_data']['score'] + print("scoring output is") + print(scoring_output) + print(f"score is {score}") + if SOLUTION_VALIDATION and score != 1: + raise Exception('Did not get full score') + if not SOLUTION_VALIDATION and score != 0: + raise Exception('Got full or partial score in question') + + +def main(solution=False): + global SOLUTION_VALIDATION + global SESSION + + try: + # id of original question from library + qid = sys.argv[1].split('-')[0] + print(f"detected qid is {qid}") + + if not qid.isdigit(): + raise Exception('Could not parse Question ID') + + # solution validation from args + branch_name = sys.argv[2] if len(sys.argv) == 3 else "" + print(f"detected branch name is {branch_name}") + + SOLUTION_VALIDATION = solution or "solution" in branch_name + if SOLUTION_VALIDATION: + print(f"Doing *****SOLUTION***** Validation") + SESSION.headers.update({ + "Authorization": f"Bearer {os.environ['SOLUTION_TOKEN']}" + }) + + # get clone of question if exists + exists, question = question_exists(qid) + + if not exists: + question = clone_question(qid) + print('question copy has id', question['id']) + update_question(question, qid) + update_project_zip(question) + task_id = validate_question(question) + validator_response = check_validation_status(task_id) + process_validator_response(validator_response['response']) + except Exception as e: + print(e) + os._exit(1) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/validate_devops.py b/.github/scripts/validate_devops.py new file mode 100644 index 0000000..f60404a --- /dev/null +++ b/.github/scripts/validate_devops.py @@ -0,0 +1,304 @@ +import requests +import json +import sys +import time +import os + +OS_MAP = { + "ubuntu": "ubuntu22", + "ubuntu18": "ubuntu22", + "ubuntu22": "ubuntu22", + "rhel7": "rhel8", + "rhel8": "rhel8", + "redhat": "redhat", + "agnostic": "agnostic", +} + +TARGET_OS = set(OS_MAP.values()) +SOLUTION_VALIDATION = False + +SESSION = requests.session() +SESSION.headers.update( + { + "Authorization": f"Bearer {os.environ['HACKERRANK_TOKEN']}", + "cache-control": "no-cache", + "content-type": "application/json", + } +) + +BASE_URL = "https://www.hackerrank.com/x/api/v1" + + +def question_exists(qid): + print("finding question in company library...") + + tag_to_search = f"{qid}-solution" if SOLUTION_VALIDATION else f"{qid}" + + library_url = f"{BASE_URL}/library?limit=1&library=personal_all&tags={tag_to_search},duplicate" + response = SESSION.request("GET", library_url) + + if response.status_code != 200: + raise Exception("Failed to find question") + + data = response.json() + + if len(data["model"]["questions"]) != 0: + return True, data["model"]["questions"][0] + + return False, None + + +def create_question(): + create_url = f"{BASE_URL}/questions" + qid = sys.argv[1].split("-")[0] + name = f"{qid} solution" if SOLUTION_VALIDATION else qid # use repo name as question name + sudorank_os = os.environ.get("SUDORANK_OS", None) + + if not sudorank_os: + raise Exception("SUDORANK_OS not set") + + print(f'creating question with name "{name}" and os "{sudorank_os}"') + + response = SESSION.request( + "POST", + create_url, + data=json.dumps( + { + "type": "sudorank", + "role_type": "devops", + "custom_testcase_allowed": "True", + "version": 3, + "name": f"Copy of {name}", + "sudorank_v3": True, + "recommended_duration": 5, + "sudorank_os": sudorank_os, + "sudorank_context": "root", + } + ), + ) + + if response.status_code != 200: + raise Exception("Failed to create question") + + data = response.json() + + return data["model"] + + +def clone_question(qid): + print("cloning question...") + + clone_url = f"{BASE_URL}/questions/clone" + name = f"{qid} solution" if SOLUTION_VALIDATION else qid + payload = json.dumps( + { + "id": qid, + "name": f"Copy of {name}", + } + ) + response = SESSION.request("POST", clone_url, data=payload) + + if response.status_code != 200: + raise Exception("Failed to clone question") + + data = response.json() + + return data["question"] + + +def read_file(script_name): + repo_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + ) + file_path = f"{repo_dir}/{script_name}.sh" + if not os.path.exists(file_path): + return "" + + return open(file_path, "r").read() + + +def solution_script_for_ubuntu(solution): + return f"""cat <<"EOF" > /tmp/solve.sh +{solution} +EOF +/bin/su -c "bash /tmp/solve.sh" - ubuntu +""" + + +def get_devops_scripts(target_os): + global SOLUTION_VALIDATION + + script_names = ["setup", "check", "solve", "cleanup"] + scripts = {script_name: read_file(script_name) for script_name in script_names} + + if SOLUTION_VALIDATION: + # TODO: add user based solution execution for other os as well + if target_os == "ubuntu22": + scripts["solve"] = solution_script_for_ubuntu(scripts["solve"]) + scripts["check"] = "({solve}) && ({check})".format( + solve=scripts["solve"], check=scripts["check"] + ) + + return scripts + + +def get_target_os(sudorank_os): + if not OS_MAP.get(sudorank_os, None): + raise Exception("Target os not found") + + return OS_MAP[sudorank_os] + + +def update_question(question, qid): + sudorank_os = question.get("sudorank_os", None) or question.get( + "sudorank_draft", {} + ).get("sudorank_os") + + print(f"update question...{question['id']}..cloned..{sudorank_os}") + + original_tags = question["visible_tags_array"] + tag_to_add = f"{qid}-solution" if SOLUTION_VALIDATION else f"{qid}" + + if tag_to_add not in original_tags: + original_tags.extend([tag_to_add, "duplicate"]) + + target_os = get_target_os(sudorank_os) + + update_url = f"{BASE_URL}/questions/{question['id']}" + + payload = json.dumps( + { + "visible_tags_array": original_tags, + "sudorank_os": target_os, + "sudorank_scripts": { + "language": "bash", + **get_devops_scripts(target_os), + }, + } + ) + print(f"payload {payload}") + response = SESSION.request("PUT", update_url, data=payload) + if response.status_code != 200: + raise Exception("Failed to update cloned question") + + if not os_updated(response.json()): + print("Cloned question still have old os, retrying") + response = SESSION.request("PUT", update_url, data=payload) + + if not os_updated(response): + raise Exception("Cloned question still have old os") + + +def os_updated(data): + sudorank_draft = data["model"]["sudorank_draft"] + print(f"sudorank_draft {sudorank_draft}") + + target_os = sudorank_draft["sudorank_os"] + print(f"Cloned question os {target_os}") + return target_os in TARGET_OS + + +def validate_question(question): + print("validating question...") + validate_url = f"{BASE_URL}/questions/{question['id']}/validate_sudorank" + + response = SESSION.post(validate_url) + print(f"validate response {response.content}") + + if response.status_code != 200: + raise Exception("Failed to start validation...") + + return response.json()["task_id"] + + +def check_validation_status(task_id): + print("polling on validation task with id", task_id) + poll_url = f"{BASE_URL}/delayed_tasks/{task_id}/poll" + + while True: + time.sleep(5) + response = SESSION.get(poll_url) + + if response.status_code != 200: + raise Exception( + "Failed to get validation status", response.status_code, response.text + ) + + data = response.json() + if ( + data["status_code"] == 2 + or data["response"]["additional_data"]["valid"] == True + ): + print(data) + return data + + +def process_validator_response(validator_response): + print("processing validator response...") + if not validator_response["valid"]: + for step, value in validator_response["data"].items(): + if value["valid"] == False: + print(step, value) + raise Exception(value["error"]) + + print("validated successfully") + + scoring_output = validator_response["data"]["validate_scoring_output"] + + if SOLUTION_VALIDATION and not scoring_output["details"]: + raise Exception("scoring output details is empty") + + if scoring_output["details"]: + score = scoring_output["details"]["scoring_data"]["score"] + print("scoring output is") + print(scoring_output) + print(f"score is {score}") + if SOLUTION_VALIDATION and score != 1: + raise Exception("Did not get full score") + if not SOLUTION_VALIDATION and score != 0: + raise Exception("Got full or partial score in question") + + +def main(solution=False): + global SOLUTION_VALIDATION + global SESSION + + try: + # id of original question from library + qid = sys.argv[1].split("-")[0] + print(f"detected qid is {qid}") + + if not qid.isdigit(): + raise Exception("Could not parse Question ID") + + SOLUTION_VALIDATION = solution + + if SOLUTION_VALIDATION: + print(f"Doing *****SOLUTION***** Validation") + SESSION.headers.update( + {"Authorization": f"Bearer {os.environ['SOLUTION_TOKEN']}"} + ) + + # get clone of question if exists + exists, question = question_exists(qid) + + if not exists: + try: + question = clone_question(qid) + except Exception as e: + print(e) # create question if clone fails + question = create_question() + + print("question copy has id", question["id"]) + update_question(question, qid) + task_id = validate_question(question) + validator_response = check_validation_status(task_id) + process_validator_response(validator_response["response"]) + except Exception as e: + print(e) + os._exit(1) + + +if __name__ == "__main__": + main(solution=False) + main(solution=True) diff --git a/.github/workflows/validate_devops_workflow.yml b/.github/workflows/validate_devops_workflow.yml new file mode 100644 index 0000000..0217a41 --- /dev/null +++ b/.github/workflows/validate_devops_workflow.yml @@ -0,0 +1,59 @@ +name: HackerRank DevOps Validation +on: + workflow_dispatch: + # accept inputs from the user for sudorank_os + inputs: + sudorank_os: + description: "SudoRank OS" + required: false + default: "ubuntu22" + type: choice + options: + - "ubuntu22" + - "rhel8" + - "agnostic" + +env: + SOLUTION_TOKEN: ${{ secrets.SOLUTION_TOKEN }} + HACKERRANK_TOKEN: ${{ secrets.QUESTION_TOKEN }} + HACKERRANK_STACKS: ${{ vars.HACKERRANK_STACKS }} +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 7 + steps: + - name: Validate repo + run: | + if [[ $REPO_NAME == [[:digit:]]* ]] + then + echo 'Repo is valid =>' $REPO_NAME + else + echo 'Repo not starting with digit =>' $REPO_NAME + exit 1 + fi + shell: bash + env: + REPO_NAME: ${{ github.event.repository.name }} + - name: Checkout branch + uses: actions/checkout@v6.0.3 + - name: Setup Python + uses: actions/setup-python@v6.2.0 + with: + python-version: "pypy3.9" + - run: pip install -r ./.github/scripts/requirements.txt + name: Install script dependencies + - run: python -u ./.github/scripts/validate_devops.py ${{ github.event.repository.name }} + name: Validate DevOps + env: + SUDORANK_OS: ${{ inputs.sudorank_os }} + - name: Find Pull Request + uses: juliangruber/find-pull-request-action@v1.11.0 + id: find-pull-request + with: + branch: ${{ github.ref_name }} + - name: Approve Pull Request if found + uses: juliangruber/approve-pull-request-action@v2.1.0 + if: ${{ steps.find-pull-request.outputs.number != '' }} + with: + github-token: ${{ github.token }} + number: ${{ steps.find-pull-request.outputs.number }} diff --git a/.github/workflows/validate_workflow.yml b/.github/workflows/validate_workflow.yml new file mode 100644 index 0000000..743bebe --- /dev/null +++ b/.github/workflows/validate_workflow.yml @@ -0,0 +1,120 @@ +name: HackerRank Validation +on: + workflow_dispatch: + inputs: + targetStack: + description: "Target Stack" + required: true + default: "based_on_current_stack" + type: choice + options: + - based_on_current_stack + - cpp12_1 + - go1_20 + - nodejs18_15 + - nodejs18_15__vuejs + - nodejs18_15__reactjs + - nodejs18_15__angularjs + - java17_0__spring_boot + - java17_0__gradle + - java17_0__maven + - ruby3_2__rails + - python3_11__django + - dotnet6_0 + - spark3_4 + - pyspark3_4 + - php8_2 + - php8_2__codeigniter + - php8_2__symfony + - php8_2__laravel + - expo6_3__react_native + - kotlin1_8_android9477386 + - java17_0_android9477386 + - java21_0__springboot + - java21_0__gradle + - java21_0__maven + - kotlin2_1_android13114758 + - node_22_mongo_8__mern + - node_22_mongo_8__mean + - java21_0_android13114758 + - cpp14 + - java21_0__cucumber + - ruby3_4__rails + - spark3_5 + - pyspark3_5 + - python3_12__django + - php8_4__symfony + - php8_4__laravel + - php8_4__codeigniter + - php8_4 + - dotnet8_0 + - nodejs22_16__vuejs + - nodejs22_16 + - nodejs22_16__angularjs + - nodejs22_16__reactjs + - go1_23 + - jam__java_21__mongo8 + - dam__python3_12__mongo8 + - expo__node_22__react_native + - flutter_web__node22 + - dam__python3_13__mongo8 + - python3_13__django + - go1_26 + - python3_13__flask + - python3_13__flask__psql + - go1_26__mysql8 + - go1_26__mongo8 + - nodejs24 + - nodejs24__reactjs + - nodejs24__angularjs + - nodejs24__vuejs + - java25_0__springboot + - jam__springboot__mongo8 + - java25_0__maven + +env: + SOLUTION_TOKEN: ${{ secrets.SOLUTION_TOKEN }} + HACKERRANK_TOKEN: ${{ secrets.QUESTION_TOKEN }} + HACKERRANK_STACKS: ${{ vars.HACKERRANK_STACKS }} +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 7 + steps: + - name: Validate repo + run: | + if [[ $REPO_NAME == [[:digit:]]* ]] + then + echo 'Repo is valid =>' $REPO_NAME + else + echo 'Repo not starting with digit =>' $REPO_NAME + exit 1 + fi + shell: bash + env: + REPO_NAME: ${{ github.event.repository.name }} + - name: Checkout branch + uses: actions/checkout@v6.0.3 + - name: Setup Python + uses: actions/setup-python@v6.2.0 + with: + python-version: "pypy3.9" + - run: git archive --format=zip -o project.zip HEAD ':!.github' + name: Create Project zip + - run: pip install -r ./.github/scripts/requirements.txt + name: Install script dependencies + - run: python -u ./.github/scripts/validate.py ${{ github.event.repository.name }} ${{ github.ref_name }} + name: Validate + env: + TARGET_STACK: ${{ inputs.targetStack }} + - name: Find Pull Request + uses: juliangruber/find-pull-request-action@v1.11.0 + id: find-pull-request + with: + branch: ${{ github.ref_name }} + - name: Approve Pull Request if found + uses: juliangruber/approve-pull-request-action@v2.1.0 + if: ${{ steps.find-pull-request.outputs.number != '' }} + with: + github-token: ${{ github.token }} + number: ${{ steps.find-pull-request.outputs.number }}