diff --git a/.github/workflows/add-submodules.yml b/.github/workflows/add-submodules.yml index a8379b8..6221006 100644 --- a/.github/workflows/add-submodules.yml +++ b/.github/workflows/add-submodules.yml @@ -25,6 +25,9 @@ concurrency: group: add-submodules-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: add-submodules: runs-on: ubuntu-latest @@ -33,6 +36,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ secrets.SYNC_TOKEN }} + persist-credentials: false - name: Add submodules shell: bash diff --git a/.github/workflows/assets/notify.sh b/.github/workflows/assets/notify.sh new file mode 100644 index 0000000..2b6fc2a --- /dev/null +++ b/.github/workflows/assets/notify.sh @@ -0,0 +1,104 @@ +# shellcheck shell=bash +# Slack failure notifications for sync-translation and heartbeat workflows. +# Source env.sh before notify.sh when using workflow globals (e.g. HEARTBEAT_MAX_AGE_HOURS). + +resolve_run_id() { + echo "${1:-${GITHUB_RUN_ID:-}}" +} + +github_actions_url() { + local path="$1" + echo "${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY}/actions/${path}" +} + +# Return workflow run URL for the given run id (defaults to GITHUB_RUN_ID). +workflow_run_url() { + local run_id + run_id="$(resolve_run_id "${1:-}")" + github_actions_url "runs/${run_id}" +} + +# Extract matrix lang from a sync-local job name, e.g. "sync-local (zh_Hans)" → zh_Hans. +# Prints nothing for non-matrix job names (e.g. discover). +extract_sync_local_lang_from_job_name() { + local job_name="$1" + local lang + lang="$(sed -n 's/.*sync-local[[:space:]]*(\([^)]*\)).*/\1/p' <<<"$job_name")" + if [[ -n "$lang" && "$lang" != "$job_name" ]]; then + echo "$lang" + fi +} + +# Read gh api /actions/runs/.../jobs JSON from stdin; emit human-readable failed-job lines. +format_failed_jobs_summary() { + jq -r ' + (if type == "object" and has("jobs") then .jobs else . end) + | .[] + | select(.conclusion == "failure") + | select(.name | test("notify-failure") | not) + | .name as $name + | ($name | if test("sync-local \\(") + then (capture("sync-local \\((?[^)]+)\\)") | .lang // "") + else "" end) as $lang + | if ($lang | length) > 0 then " • \($name) — lang=\($lang)" + else " • \($name)" end + ' +} + +# Build a Slack incoming-webhook JSON payload. Args: title run_url summary_lines detail (optional). +build_slack_failure_payload() { + local title="$1" run_url="$2" summary="$3" detail="${4:-}" + local text="${title}"$'\n'"Run: ${run_url}" + if [[ -n "$summary" ]]; then + text+=$'\n'"Failed jobs:"$'\n'"${summary}" + fi + if [[ -n "$detail" ]]; then + text+=$'\n'"${detail}" + fi + jq -n --arg text "$text" '{text: $text}' +} + +# POST JSON payload to SLACK_WEBHOOK_URL. +send_slack_notification() { + local payload="$1" + if [[ -z "${SLACK_WEBHOOK_URL:-}" ]]; then + echo "error: SLACK_WEBHOOK_URL secret is not set." >&2 + return 1 + fi + curl -fsS --connect-timeout 10 --max-time 30 \ + -X POST -H 'Content-Type: application/json' --data "$payload" "$SLACK_WEBHOOK_URL" +} + +# Fetch failed jobs for a workflow run and format summary lines. +collect_workflow_failed_jobs_summary() { + local run_id + run_id="$(resolve_run_id "${1:-}")" + gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs \ + | format_failed_jobs_summary +} + +# Notify Slack about sync-translation workflow failures (discover or sync-local). +notify_sync_translation_failure() { + local run_id + run_id="$(resolve_run_id "${1:-}")" + local run_url summary payload + run_url="$(workflow_run_url "$run_id")" + summary="$(collect_workflow_failed_jobs_summary "$run_id")" || summary="(details unavailable)" + payload="$(build_slack_failure_payload "Sync translation failed" "$run_url" "$summary" "")" + send_slack_notification "$payload" +} + +# Notify Slack when the daily sync heartbeat is stale. +notify_heartbeat_stale() { + local last_ts="$1" + local run_url workflow_url detail payload + local last_desc="${last_ts:-none on record}" + + run_url="$(workflow_run_url)" + workflow_url="$(github_actions_url "workflows/sync-translation.yml")" + detail="Daily sync heartbeat: last successful scheduled run is stale (last=${last_desc}, threshold=${HEARTBEAT_MAX_AGE_HOURS}h). The scheduled sync may have stopped." + detail+=$'\n'"Workflow: ${workflow_url}" + + payload="$(build_slack_failure_payload "Sync heartbeat alert" "$run_url" "" "$detail")" + send_slack_notification "$payload" +} diff --git a/.github/workflows/heartbeat.yml b/.github/workflows/heartbeat.yml index 20c6d75..17f8c7c 100644 --- a/.github/workflows/heartbeat.yml +++ b/.github/workflows/heartbeat.yml @@ -6,6 +6,8 @@ # alerts when the last *successful* scheduled sync is older than a threshold, so a # silently missed run is caught. It does not depend on sync-translation.yml # running, so it still fires once that workflow has stopped. +# +# Required secret: SLACK_WEBHOOK_URL — Slack incoming webhook for stale-heartbeat alerts. name: Sync heartbeat @@ -24,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository (master) - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -32,6 +34,7 @@ jobs: env: # Ephemeral Actions token for gh CLI — not the SYNC_TOKEN PAT in GITHUB_TOKEN elsewhere. GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | set -euo pipefail @@ -39,6 +42,8 @@ jobs: source "$GITHUB_WORKSPACE/.github/workflows/assets/env.sh" # shellcheck source=assets/lib.sh source "$GITHUB_WORKSPACE/.github/workflows/assets/lib.sh" + # shellcheck source=assets/notify.sh + source "$GITHUB_WORKSPACE/.github/workflows/assets/notify.sh" begin_phase "$PHASE_HEARTBEAT" "Check daily sync heartbeat" trap '[[ -n "$CURRENT_PHASE" ]] && end_phase' EXIT ERR @@ -62,10 +67,9 @@ jobs: max_age_seconds=$(( HEARTBEAT_MAX_AGE_HOURS * 3600 )) if heartbeat_run_is_stale "$last_epoch" "$now_epoch" "$max_age_seconds"; then - # Interim alert: fail the job. Swap this for the shared notification - # channel once the failure-notification work provides one. last_desc="${last_ts:-none on record}" echo "::error::Daily sync heartbeat: last successful scheduled run is stale (last=${last_desc}, threshold=${HEARTBEAT_MAX_AGE_HOURS}h). The scheduled sync may have stopped." >&2 + notify_heartbeat_stale "$last_ts" end_phase exit 1 fi diff --git a/.github/workflows/sync-translation.yml b/.github/workflows/sync-translation.yml index b85b578..bfada20 100644 --- a/.github/workflows/sync-translation.yml +++ b/.github/workflows/sync-translation.yml @@ -11,8 +11,10 @@ # Manual trigger: POST /repos/{owner}/{repo}/dispatches # Body: {"event_type": "sync-translation"} # -# Required secret: SYNC_TOKEN — PAT with repo push to this repository. -# Submodule remotes in .gitmodules point at per-library org (set when running add-submodules / start-translation with SUBMODULES_ORG). +# Required secrets: +# SYNC_TOKEN — PAT with repo push to this repository. +# Submodule remotes in .gitmodules point at per-library org (set when running add-submodules / start-translation with SUBMODULES_ORG). +# SLACK_WEBHOOK_URL — Slack incoming webhook for failure alerts (notify-failure job). name: Sync translation @@ -148,3 +150,32 @@ jobs: end_phase echo "Done." >&2 + + notify-failure: + needs: [discover, sync-local] + if: >- + always() && + (needs.discover.result == 'failure' || needs.sync-local.result == 'failure') + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + steps: + - name: Checkout repository (master) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Notify Slack on failure + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + # shellcheck source=assets/env.sh + source "$GITHUB_WORKSPACE/.github/workflows/assets/env.sh" + # shellcheck source=assets/notify.sh + source "$GITHUB_WORKSPACE/.github/workflows/assets/notify.sh" + + notify_sync_translation_failure diff --git a/CHANGELOG.md b/CHANGELOG.md index f9fad36..8529076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ are a separate namespace — see [README](README.md#releases) and - Lint/CI schema checks via pinned `check-jsonschema` (metaschema + fixture validation); bats validates captured Weblate request bodies against the schema and asserts success response fields. +- Slack failure notifications for `sync-translation` (via `notify-failure` job) + and stale-sync alerts for `heartbeat` (shared `notify.sh` + `SLACK_WEBHOOK_URL` + secret). ### Changed diff --git a/README.md b/README.md index deef316..11ae16f 100644 --- a/README.md +++ b/README.md @@ -203,9 +203,10 @@ only in the local trigger scripts, not across client and workflow contexts. | Secret | Used by | Description | | --------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `SYNC_TOKEN` | all workflows | PAT with **`repo`** scope; **`add-submodules`** also needs permission to create org repositories when creating new mirrors. | +| `SYNC_TOKEN` | `add-submodules`, `start-translation`, `sync-translation` | PAT with **`repo`** scope; **`add-submodules`** also needs permission to create org repositories when creating new mirrors. | | `WEBLATE_URL` | `start-translation` | Base URL of the Weblate instance (the workflow appends **`WEBLATE_ENDPOINT_PATH`**). | | `WEBLATE_TOKEN` | `start-translation` | API token for that endpoint. | +| `SLACK_WEBHOOK_URL` | `sync-translation`, `heartbeat` | Slack incoming webhook URL for failure and stale-heartbeat alerts. | ## Repository variables diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 1d06fc9..b23b570 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -33,7 +33,7 @@ flowchart TD | Step | When | Workflow | Trigger | Script / client | | ---- | ---- | -------- | ------- | ----------------- | -| 0 | Always first | — | — | GitHub secrets/vars: `SYNC_TOKEN`, `WEBLATE_URL`, `WEBLATE_TOKEN`, `LANG_CODES`, optional `SUBMODULES_ORG` | +| 0 | Always first | — | — | GitHub secrets/vars: `SYNC_TOKEN`, `WEBLATE_URL`, `WEBLATE_TOKEN`, `SLACK_WEBHOOK_URL`, `LANG_CODES`, optional `SUBMODULES_ORG` | | 1 | Optional | — | — | `cp .env.example .env` → `GH_TOKEN` | | 2 | Greenfield / new libs | `add-submodules.yml` | `event_type: add-submodules` | `scripts/trigger-add-submodules.sh` | | 3 | After mirrors exist | `start-translation.yml` | `event_type: start-translation` | `scripts/trigger-start-translation.sh` | @@ -55,12 +55,15 @@ Configure these on the translations repository **before** dispatching any workfl | Secret | `SYNC_TOKEN` | [README § Required secrets](../README.md#required-secrets) | | Secret | `WEBLATE_URL` | [README § Required secrets](../README.md#required-secrets) | | Secret | `WEBLATE_TOKEN` | [README § Required secrets](../README.md#required-secrets) | +| Secret | `SLACK_WEBHOOK_URL` | [README § Required secrets](../README.md#required-secrets) — Slack incoming webhook for `sync-translation` failure and `heartbeat` stale-sync alerts | | Variable | `LANG_CODES` | [README § Repository variables](../README.md#repository-variables) | | Variable | `SUBMODULES_ORG` | [README § Repository variables](../README.md#repository-variables) | -`SYNC_TOKEN`, `WEBLATE_URL`, and `WEBLATE_TOKEN` are required secrets; +`SYNC_TOKEN`, `WEBLATE_URL`, `WEBLATE_TOKEN`, and `SLACK_WEBHOOK_URL` are required secrets; `LANG_CODES` and optional `SUBMODULES_ORG` are repository variables. See the -linked README sections for scope, format, and which workflows consume each value. +linked README sections for scope and format. **`sync-translation`** requires +**`SYNC_TOKEN`** for `discover` and `sync-local`; its `notify-failure` job requires +**`SLACK_WEBHOOK_URL`**. --- @@ -273,8 +276,8 @@ For each remote **`${LOCAL_BRANCH_PREFIX}*`** branch in the super-repo: checkout with submodules, set each submodule's tracking branch to that name, run `git submodule update --remote`, commit if pointers changed, and force-push. -Requires secret **`SYNC_TOKEN`** only. Relies on **`.gitmodules`** URLs established -by steps 2–3. +Requires secrets **`SYNC_TOKEN`** (`discover`, `sync-local`) and **`SLACK_WEBHOOK_URL`** +(`notify-failure`). Relies on **`.gitmodules`** URLs established by steps 2–3. ### Verify diff --git a/scripts/lint.sh b/scripts/lint.sh index bf86e24..aa5a7e6 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -169,6 +169,7 @@ echo "lint: actionlint ${ACTIONLINT_VERSION} ($("$ACTIONLINT_BIN" -version | hea "$SHELLCHECK_BIN" -x \ .github/workflows/assets/env.sh \ .github/workflows/assets/lib.sh \ + .github/workflows/assets/notify.sh \ .github/workflows/assets/translation.sh \ .github/workflows/assets/add_submodules.sh \ .github/workflows/assets/submodule_ops.sh \ diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash index 7289db3..c0a98ea 100644 --- a/tests/helpers/common.bash +++ b/tests/helpers/common.bash @@ -41,6 +41,14 @@ load_submodule_ops() { source "$ASSETS_DIR/submodule_ops.sh" } +load_notify() { + load_env + export GITHUB_RUN_ID="${GITHUB_RUN_ID:-12345}" + set -euo pipefail + # shellcheck source=/dev/null + source "$ASSETS_DIR/notify.sh" +} + # Run a function and capture its exit code (works under set -e in callers). run_fn() { local errexit_was_on=0 diff --git a/tests/helpers/http_mock.bash b/tests/helpers/http_mock.bash index 3943b7c..82e2a5d 100644 --- a/tests/helpers/http_mock.bash +++ b/tests/helpers/http_mock.bash @@ -181,6 +181,44 @@ restore_curl_stub() { unset CURL_WRAPPER_DIR REAL_CURL _CURL_ORIG_PATH MOCK_CURL_TIMEOUT MOCK_CURL_EXIT } +install_slack_curl_stub() { + _init_curl_wrapper_dir + MOCK_SLACK_REQUEST_LOG="$(mktemp)" + export MOCK_SLACK_REQUEST_LOG + { + echo '#!/usr/bin/env bash' + _curl_stub_preamble + cat <<'EOF' +if [[ -n "${SLACK_WEBHOOK_URL:-}" && " $* " == *" ${SLACK_WEBHOOK_URL} "* ]]; then + body="" + prev="" + for arg in "$@"; do + if [[ "$prev" == "--data" || "$prev" == "-d" ]]; then + body="$arg" + fi + prev="$arg" + done + { + echo "URL=$SLACK_WEBHOOK_URL" + echo "BODY_START" + printf '%s' "$body" + echo + echo "BODY_END" + } >"${MOCK_SLACK_REQUEST_LOG}" + exit 0 +fi +exec "$REAL_CURL" "$@" +EOF + } >"$CURL_WRAPPER_DIR/curl" + _activate_curl_wrapper +} + +restore_slack_curl_stub() { + [[ -n "${MOCK_SLACK_REQUEST_LOG:-}" && -f "$MOCK_SLACK_REQUEST_LOG" ]] && rm -f "$MOCK_SLACK_REQUEST_LOG" + unset MOCK_SLACK_REQUEST_LOG + restore_curl_stub +} + # Intercept GitHub repository_dispatch POST (no real network). install_dispatch_curl_stub() { _init_curl_wrapper_dir diff --git a/tests/helpers/mock_gh.bash b/tests/helpers/mock_gh.bash index c9d7052..02e7fa8 100644 --- a/tests/helpers/mock_gh.bash +++ b/tests/helpers/mock_gh.bash @@ -60,6 +60,19 @@ case "$cmd" in exit "${MOCK_PR_LIST_EXIT:-0}" fi ;; + run) + if [[ "${1:-}" == "view" ]]; then + if [[ "${MOCK_RUN_VIEW_EXIT:-0}" -ne 0 ]]; then + exit "${MOCK_RUN_VIEW_EXIT}" + fi + if [[ -n "${MOCK_RUN_VIEW_JSON:-}" ]]; then + printf '%s\n' "$MOCK_RUN_VIEW_JSON" + else + printf '%s\n' '{"jobs":[]}' + fi + exit 0 + fi + ;; esac echo "mock gh: unhandled invocation: gh $*" >&2 @@ -80,13 +93,16 @@ restore_mock_gh() { unset MOCK_REPO_VIEW_EXIT MOCK_LIBRARIES_FIXTURE MOCK_GH_API_EXIT unset MOCK_REPO_CREATE_EXIT MOCK_GH_PATCH_LOG MOCK_GH_PATCH_EXIT unset MOCK_PR_LIST_STDOUT MOCK_PR_LIST_STDERR MOCK_PR_LIST_EXIT + unset MOCK_RUN_VIEW_EXIT MOCK_RUN_VIEW_JSON } reset_mock_gh() { export MOCK_REPO_VIEW_EXIT=0 export MOCK_GH_PATCH_EXIT=0 + export MOCK_RUN_VIEW_EXIT=0 unset MOCK_LIBRARIES_FIXTURE MOCK_GH_API_EXIT MOCK_REPO_CREATE_EXIT unset MOCK_PR_LIST_STDOUT MOCK_PR_LIST_STDERR MOCK_PR_LIST_EXIT + unset MOCK_RUN_VIEW_JSON export MOCK_PR_LIST_EXIT=0 if [[ -n "${MOCK_GH_DIR:-}" ]]; then MOCK_GH_PATCH_LOG="$MOCK_GH_DIR/gh-patch.log" diff --git a/tests/test_notify.bats b/tests/test_notify.bats new file mode 100644 index 0000000..59fbdbd --- /dev/null +++ b/tests/test_notify.bats @@ -0,0 +1,155 @@ +#!/usr/bin/env bats + +setup() { + # shellcheck source=tests/helpers/common.bash + source "$BATS_TEST_DIRNAME/helpers/common.bash" + load_notify +} + +teardown() { + # shellcheck source=tests/helpers/http_mock.bash + source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" + restore_slack_curl_stub + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + restore_mock_gh +} + +@test "extract_sync_local_lang_from_job_name: parses matrix lang" { + run extract_sync_local_lang_from_job_name "sync-local (zh_Hans)" + [ "$status" -eq 0 ] + [ "$output" = "zh_Hans" ] +} + +@test "extract_sync_local_lang_from_job_name: parses prefixed workflow job name" { + run extract_sync_local_lang_from_job_name "Sync translation / sync-local (ja)" + [ "$status" -eq 0 ] + [ "$output" = "ja" ] +} + +@test "extract_sync_local_lang_from_job_name: discover returns empty" { + run extract_sync_local_lang_from_job_name "discover" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "format_failed_jobs_summary: lists failed discover and sync-local jobs with langs" { + local fixture + fixture='{ + "jobs": [ + {"name": "discover", "conclusion": "failure"}, + {"name": "sync-local (zh_Hans)", "conclusion": "failure"}, + {"name": "sync-local (ja)", "conclusion": "failure"}, + {"name": "sync-local (en)", "conclusion": "success"}, + {"name": "notify-failure", "conclusion": "failure"} + ] + }' + run bash -c "source '${ASSETS_DIR}/notify.sh'; format_failed_jobs_summary" <<<"$fixture" + [ "$status" -eq 0 ] + [[ "$output" == *"• discover"* ]] + [[ "$output" == *"sync-local (zh_Hans) — lang=zh_Hans"* ]] + [[ "$output" == *"sync-local (ja) — lang=ja"* ]] + [[ "$output" != *"sync-local (en)"* ]] + [[ "$output" != *"notify-failure"* ]] +} + +@test "build_slack_failure_payload: valid JSON with run URL and summary" { + run build_slack_failure_payload \ + "Sync translation failed" \ + "https://github.com/org/repo/actions/runs/99" \ + $' • discover\n • sync-local (ja) — lang=ja' + [ "$status" -eq 0 ] + echo "$output" | jq -e . >/dev/null + [[ "$output" == *"https://github.com/org/repo/actions/runs/99"* ]] + [[ "$output" == *"Sync translation failed"* ]] + [[ "$output" == *"lang=ja"* ]] +} + +@test "send_slack_notification: missing SLACK_WEBHOOK_URL exits 1" { + unset SLACK_WEBHOOK_URL + run send_slack_notification '{"text":"hi"}' + [ "$status" -eq 1 ] + [[ "$output" == *"SLACK_WEBHOOK_URL"* ]] +} + +@test "send_slack_notification: posts JSON to webhook URL" { + # shellcheck source=tests/helpers/http_mock.bash + source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" + install_slack_curl_stub + export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T/B/x" + run send_slack_notification '{"text":"alert"}' + [ "$status" -eq 0 ] + grep -q 'URL=https://hooks.slack.com/services/T/B/x' "$MOCK_SLACK_REQUEST_LOG" + grep -q '"text":"alert"' "$MOCK_SLACK_REQUEST_LOG" +} + +@test "workflow_run_url: uses GITHUB_SERVER_URL and GITHUB_REPOSITORY" { + export GITHUB_SERVER_URL="https://github.example" + export GITHUB_REPOSITORY="myorg/myrepo" + export GITHUB_RUN_ID="42" + run workflow_run_url + [ "$status" -eq 0 ] + [ "$output" = "https://github.example/myorg/myrepo/actions/runs/42" ] +} + +@test "notify_sync_translation_failure: no-arg call posts Slack under set -u" { + # shellcheck source=tests/helpers/http_mock.bash + source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" + install_slack_curl_stub + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + install_mock_gh + reset_mock_gh + export MOCK_RUN_VIEW_JSON='{"jobs":[{"name":"discover","conclusion":"failure"}]}' + export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T/B/x" + export GITHUB_REPOSITORY="myorg/myrepo" + export GITHUB_RUN_ID="4242" + run notify_sync_translation_failure + [ "$status" -eq 0 ] + grep -q 'Sync translation failed' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'actions/runs/4242' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'discover' "$MOCK_SLACK_REQUEST_LOG" +} + +@test "notify_sync_translation_failure: posts alert when gh run view fails" { + # shellcheck source=tests/helpers/http_mock.bash + source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" + install_slack_curl_stub + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + install_mock_gh + reset_mock_gh + export MOCK_RUN_VIEW_EXIT=1 + export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T/B/x" + export GITHUB_REPOSITORY="myorg/myrepo" + export GITHUB_RUN_ID="99" + run notify_sync_translation_failure + [ "$status" -eq 0 ] + grep -q 'Sync translation failed' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'details unavailable' "$MOCK_SLACK_REQUEST_LOG" +} + +@test "notify_heartbeat_stale: builds payload with threshold detail" { + # shellcheck source=tests/helpers/http_mock.bash + source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" + install_slack_curl_stub + export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T/B/x" + export GITHUB_RUN_ID="42" + run notify_heartbeat_stale "2026-01-01T00:00:00Z" + [ "$status" -eq 0 ] + grep -q 'Sync heartbeat alert' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'last=2026-01-01T00:00:00Z' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'threshold=30h' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'actions/runs/42' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'sync-translation.yml' "$MOCK_SLACK_REQUEST_LOG" +} + +@test "notify_heartbeat_stale: empty timestamp uses none on record" { + # shellcheck source=tests/helpers/http_mock.bash + source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" + install_slack_curl_stub + export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T/B/x" + run notify_heartbeat_stale "" + [ "$status" -eq 0 ] + grep -q 'last=none on record' "$MOCK_SLACK_REQUEST_LOG" +}