diff --git a/.github/workflows/demo-release-closed-poll.yml b/.github/workflows/demo-release-closed-poll.yml new file mode 100644 index 00000000..9ca0e095 --- /dev/null +++ b/.github/workflows/demo-release-closed-poll.yml @@ -0,0 +1,88 @@ +name: Testops - TestRail Milestone CLOSED - polling + +on: + pull_request: + push: + branches: [ rpapa-release-close] + workflow_dispatch: + inputs: + branchName: + description: "rpapa branch" + required: true + default: "rpapa-release-close" + +env: + CLOUD_SQL_DATABASE_NAME: preflight + CLOUD_SQL_DATABASE_USERNAME: ${{ secrets.CLOUD_SQL_DATABASE_USERNAME }} + CLOUD_SQL_DATABASE_PASSWORD: ${{ secrets.CLOUD_SQL_DATABASE_PASSWORD }} + CLOUD_SQL_DATABASE_PORT: ${{ secrets.CLOUD_SQL_DATABASE_PORT }} + TESTRAIL_HOST: ${{ secrets.TESTRAIL_HOST }} + TESTRAIL_USERNAME: ${{ secrets.TESTRAIL_USERNAME }} + TESTRAIL_PASSWORD: ${{ secrets.TESTRAIL_PASSWORD }} + ATLASSIAN_API_TOKEN: ${{ secrets.ATLASSIAN_API_TOKEN }} + ATLASSIAN_HOST: ${{ secrets.ATLASSIAN_HOST }} + ATLASSIAN_USERNAME: ${{ secrets.ATLASSIAN_USERNAME }} + JIRA_HOST: ${{ secrets.JIRA_HOST }} + JIRA_USER: ${{ secrets.JIRA_USER }} + JIRA_PASSWORD: ${{ secrets.JIRA_PASSWORD }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUGZILLA_API_KEY: ${{ secrets.BUGZILLA_API_KEY }} + BITRISE_HOST: ${{ secrets.BITRISE_HOST }} + BITRISE_APP_SLUG: ${{ secrets.BITRISE_APP_SLUG }} + BITRISE_TOKEN: ${{ secrets.BITRISE_TOKEN }} + SENTRY_HOST: ${{ secrets.SENTRY_HOST }} + SENTRY_API_TOKEN: ${{ secrets.SENTRY_API_TOKEN_CSO }} + SENTRY_ORGANIZATION_SLUG: ${{ secrets.SENTRY_ORGANIZATION_SLUG }} + SENTRY_IOS_PROJECT_ID: ${{ secrets.SENTRY_IOS_PROJECT_ID }} + SENTRY_FENIX_PROJECT_ID: ${{ secrets.SENTRY_FENIX_PROJECT_ID }} + SENTRY_FENIX_BETA_PROJECT_ID: ${{ secrets.SENTRY_FENIX_BETA_PROJECT_ID }} + +jobs: + reports: + name: Run reports (${{ matrix.name }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + max-parallel: 6 + matrix: + include: + - name: Mobile Testrail Milestones CLOSED + args: --platform mobile --project ALL --report-type testrail-milestones-closed + + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: requirements.txt + - uses: mattes/gce-cloudsql-proxy-action@v1 + with: + creds: ${{ secrets.GCLOUD_AUTH }} + instance: ${{ secrets.CLOUD_SQL_CONNECTION_NAME }} + port: ${{ secrets.CLOUD_SQL_DATABASE_PORT }} + - run: pip install -r requirements.txt + - run: python ./__main__.py ${{ matrix.args }} + + notify: + name: Slack notify + runs-on: ubuntu-latest + needs: [reports, sentry] + if: always() + steps: + - uses: actions/checkout@v5 + - run: echo "JOB_LOG_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_ENV + - uses: slackapi/slack-github-action@v2.1.1 + env: + WORKFLOW_NAME: ${{ github.workflow }} + BRANCH: ${{ github.head_ref || github.ref_name }} + JOB_STATUS: ${{ (needs.reports.result == 'success' && needs.sentry.result == 'success') && ':white_check_mark:' || ':x:' }} + JOB_STATUS_COLOR: ${{ (needs.reports.result == 'success' && needs.sentry.result == 'success') && '#36a64f' || '#FF0000' }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_MOBILE_ALERTS_TOOLING }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL_MOBILE_ALERTS_TOOLING }} + webhook-type: webhook-trigger + payload-file-path: "./config/payload-slack-content.json" + payload-templated: true diff --git a/.github/workflows/preflight-push.yaml b/.github/workflows/preflight-push.yaml index 5ce118b1..6242131d 100644 --- a/.github/workflows/preflight-push.yaml +++ b/.github/workflows/preflight-push.yaml @@ -57,6 +57,8 @@ jobs: args: --report-type testrail-users - name: Mobile Testrail Milestones args: --platform mobile --project ALL --report-type testrail-milestones + - name: Mobile Testrail Milestones CLOSED + args: --platform mobile --project ALL --report-type testrail-milestones-closed - name: Jira qa-needed args: --report-type jira-qa-needed - name: Jira qa-requests diff --git a/__main__.py b/__main__.py index 4e6d1a71..df04ddb1 100644 --- a/__main__.py +++ b/__main__.py @@ -53,6 +53,7 @@ handle_testrail_test_plans_and_runs, handle_testrail_test_results, handle_testrail_milestones, + handle_testrail_milestones_closed, handle_testrail_users, handle_testrail_test_case_coverage, # handle_testrail_test_run_counts_update, @@ -111,7 +112,7 @@ def parse_args(cmdln_args): def validate_project(platform, project, report_type): # Conditionally require --platform and --project # if --report-type is 'test-case-coverage' - if report_type in ('test-case-coverage', 'testrail-milestones'): + if report_type in ('test-case-coverage', 'testrail-milestones', 'testrail-milestones-closed'): if not project: print("--project is required for the report selected") if not platform: @@ -185,6 +186,7 @@ def expand_project_args(platform, projects): 'sentry-issues': handle_sentry_issues, 'sentry-rates': handle_sentry_rates, 'testrail-milestones': handle_testrail_milestones, + 'testrail-milestones-closed': handle_testrail_milestones_closed, 'testrail-users': handle_testrail_users, 'testrail-test-case-coverage': handle_testrail_test_case_coverage, # 'testrail-test-run-counts': handle_testrail_test_run_counts_update, @@ -203,6 +205,7 @@ def main(): print(f"args.report_type: {args.report_type}") print(f"args.arg_list: {args.arg_list}") + if report_type not in COMMAND_MAP: sys.exit(f"Unknown or unsupported report type: {report_type}") diff --git a/api/testrail/report_milestones.py b/api/testrail/report_milestones.py index 1725b798..d7435422 100644 --- a/api/testrail/report_milestones.py +++ b/api/testrail/report_milestones.py @@ -4,7 +4,7 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. - +import sys import pandas as pd import numpy as np @@ -38,124 +38,185 @@ def _tr() -> TestRail(): return _TR -# =================================================================== -# ORCHESTRATOR (BATCH) -# =================================================================== +def select_latest_open(df): + if df.empty: + return None -def testrail_milestones(project): + # Select rows where not is_completed + open_df = df[df["is_completed"] == False] - tr = _tr() + if open_df.empty: + return None - testrail_milestones_delete() + # Sort by start date (or fallback to ID) + sort_cols = [c for c in ("started_on", "testrail_milestone_id") if c in open_df.columns] + open_df = open_df.sort_values(sort_cols, ascending=True) + + if open_df.empty: + return None + + return open_df.iloc[-1].to_dict() + + +def run(project, milestone_validate_closed: bool = False): + + # TEMP + + testrail_milestones_delete() project_ids_list = testrail_project_ids(project) + print("SET PROJECT ID to: Test Project - Mobile = 75") + project_ids_list = [[1, 75]] + + # TODO: this gets overwritten in conditional below (remove) milestones_all = pd.DataFrame() for project_ids in project_ids_list: - projects_id = project_ids[0] - testrail_project_id = project_ids[1] - - payload = tr.milestones(testrail_project_id) - if not payload: - print( - f"No milestones found for project {testrail_project_id}." - f" Skipping..." - ) - - # Empty DataFrame to avoid errors - milestones_all = pd.DataFrame() - - else: - # Convert JSON to DataFrame - milestones_all = pd.json_normalize(payload) - # Ensure DataFrame is not empty before processing - if milestones_all.empty: - print( - f"Milestones DataFrame is empty for project {testrail_project_id}." - f"Skipping..." - ) - # Continue to next project (if inside a loop) - else: - # Define selected columns - selected_columns = { - "id": "testrail_milestone_id", - "name": "name", - "started_on": "started_on", - "is_completed": "is_completed", - "description": "description", - "completed_on": "completed_on", - "url": "url" - } + # fetch - begin + payload, df_selected, testrail_project_id, projects_id = _fetch(project_ids, milestones_all) - # Select specific columns (only if they exist) - existing_columns = [ - col for col in selected_columns.keys() - if col in milestones_all.columns - ] - - df_selected = milestones_all[existing_columns].rename( - columns={ - k: v - for k, v in selected_columns.items() - if k in milestones_all.columns - } - ) + print(f"milestone_validate_closed: {milestone_validate_closed}") - # Convert valid timestamps, leave empty ones as NaT - if 'started_on' in df_selected.columns: - df_selected['started_on'] = pd.to_datetime( - df_selected['started_on'], unit='s', errors='coerce' - ) - df_selected['started_on'] = df_selected['started_on'].replace( - {np.nan: None} - ) + if df_selected is None: + df_selected = pd.DataFrame() - if 'completed_on' in df_selected.columns: - df_selected['completed_on'] = pd.to_datetime( - df_selected['completed_on'], unit='s', errors='coerce' - ) - df_selected['completed_on'] = df_selected['completed_on'].replace( - {np.nan: None} - ) + if milestone_validate_closed: + # TODO: initiate follow-on reporting here + print("NO DB INSERT") + print("------------------------------------") + print(df_selected.columns) + print("------------------------------------") - # Apply transformations only if description column exists - if 'description' in df_selected.columns: - df_selected['testing_status'] = df_selected['description'].apply( - pl.extract_testing_status - ) + latest_open = select_latest_open(df_selected) - desc_series = df_selected['description'] - df_selected['testing_recommendation'] = desc_series.apply( - pl.extract_testing_recommendation - ) + if latest_open is None: + print("There is no open milestone in this DataFrame.") + else: + print(f"Latest OPEN milestone: {latest_open['name']} (id={latest_open['testrail_milestone_id']})") + print("------------------------------------") + sys.exit() - # Apply transformations only if name column exists - if 'name' in df_selected.columns: - df_selected['build_name'] = df_selected['name'].apply( - pl.extract_build_name - ) - df_selected['build_version'] = df_selected['build_name'].apply( - pl.extract_build_version - ) # Insert into database only if there is data if not df_selected.empty: - report_milestones_insert(projects_id, df_selected) + print("DB_UPSERT") + #_db_upsert(projects_id, payload, df_selected) + _db_upsert(projects_id, df_selected) else: + print("DB_UPSERT - NO DATA") print( f"No milestones data to insert into database for project " f"{testrail_project_id}." ) + # TEMP + sys.exit() + +def _fetch(project_ids, milestones_all): + + tr = _tr() + projects_id = project_ids[0] + testrail_project_id = project_ids[1] + payload = tr.milestones(testrail_project_id) + + print("TESTRAIL_PROJECT_ID: {testrail_project_id}") + + if not payload: + print( + f"No milestones found for project {testrail_project_id}." + f" Skipping..." + ) + + # Empty DataFrame to avoid errors + milestones_all = pd.DataFrame() + + else: + # Convert JSON to DataFrame + milestones_all = pd.json_normalize(payload) + + + # Always define df_selected + df_selected = pd.DataFrame() + + # Ensure DataFrame is not empty before processing + if milestones_all.empty: + print( + f"Milestones DataFrame is empty for project {testrail_project_id}." + f"Skipping..." + ) + # Continue to next project (if inside a loop) + else: + # Define selected columns + selected_columns = { + "id": "testrail_milestone_id", + "name": "name", + "started_on": "started_on", + "is_completed": "is_completed", + "description": "description", + "completed_on": "completed_on", + "url": "url" + } + + # Select specific columns (only if they exist) + existing_columns = [ + col for col in selected_columns.keys() + if col in milestones_all.columns + ] + + df_selected = milestones_all[existing_columns].rename( + columns={ + k: v + for k, v in selected_columns.items() + if k in milestones_all.columns + } + ) + + # Convert valid timestamps, leave empty ones as NaT + if 'started_on' in df_selected.columns: + df_selected['started_on'] = pd.to_datetime( + df_selected['started_on'], unit='s', errors='coerce' + ) + df_selected['started_on'] = df_selected['started_on'].replace( + {np.nan: None} + ) + + if 'completed_on' in df_selected.columns: + df_selected['completed_on'] = pd.to_datetime( + df_selected['completed_on'], unit='s', errors='coerce' + ) + df_selected['completed_on'] = df_selected['completed_on'].replace( + {np.nan: None} + ) + + # Apply transformations only if description column exists + if 'description' in df_selected.columns: + df_selected['testing_status'] = df_selected['description'].apply( + pl.extract_testing_status + ) + + desc_series = df_selected['description'] + df_selected['testing_recommendation'] = desc_series.apply( + pl.extract_testing_recommendation + ) + + # Apply transformations only if name column exists + if 'name' in df_selected.columns: + + df_selected['build_name'] = df_selected['name'].apply( + pl.extract_build_name + ) + + df_selected['build_version'] = df_selected['build_name'].apply( + pl.extract_build_version + ) + return payload, df_selected, testrail_project_id, projects_id -# =================================================================== -# DB INSERT -# =================================================================== -def report_milestones_insert(projects_id, payload): +#def _db_upsert(projects_id, payload, df_selected): +def _db_upsert(projects_id, df_selected): # DIAGNOSTIC print("--------------------------------------") @@ -165,7 +226,8 @@ def report_milestones_insert(projects_id, payload): db = _db() - for index, row in payload.iterrows(): + #for index, row in payload.iterrows(): + for _, row in df_selected.iterrows(): report = ReportTestRailMilestones( testrail_milestone_id=row['testrail_milestone_id'], diff --git a/constants.py b/constants.py index 30f4fc6a..06342359 100644 --- a/constants.py +++ b/constants.py @@ -52,6 +52,7 @@ 'jira-qa-requests-new-issue-types', 'jira-softvision-worklogs', 'testrail-milestones', + 'testrail-milestones-closed', 'testrail-users', 'testrail-test-case-coverage', 'testrail-test-run-counts', diff --git a/handlers/testrail.py b/handlers/testrail.py index fcc7d87c..33f52dad 100644 --- a/handlers/testrail.py +++ b/handlers/testrail.py @@ -15,7 +15,12 @@ def handle_testrail_test_results(args): def handle_testrail_milestones(args): - milestones.testrail_milestones(args.arg_list) + milestones.run(args.arg_list) + + +def handle_testrail_milestones_closed(args): + milestone_validate_closed = True + milestones.run(args.arg_list, milestone_validate_closed) def handle_testrail_users(args):