From 53c85f7eed024f923561e627ded8b942acecc4ea Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:41:31 -0400 Subject: [PATCH] Add changelog-driven releases and App Home tab (#112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add changelog-driven releases and App Home tab Version the bot continuously from CHANGELOG.md (the single source of truth for both the version and the staff-readable notes) and surface changes to users in two ways: - A new afterhours-release-notifier Lambda posts a "What's New" message to the shift channel on minor/major releases (patches stay silent). - The bot gains an App Home "About" tab showing what it does, the command list, and the current version's notes. release.yaml runs on Deploy success (not release:published — GITHUB_TOKEN events don't start downstream workflows), checks out the deployed commit, and tags + publishes a GitHub Release + invokes the notifier. It assumes a dedicated, boundary-carrying OIDC role scoped to InvokeFunction on the notifier; the account's cfn role gates role creation on that boundary. The manual Version Bump workflow is retired. A CI guard enforces that a CHANGELOG edit is a clean SemVer bump and that the in-package copy matches. * Harden release workflow and regex against CodeQL findings Address three code-scanning alerts on the PR: - Critical (actions/untrusted-checkout): split release.yaml into a read-only `prepare` job that checks out and runs repo code, and a privileged `publish` job (contents:write + OIDC) that never checks out repo code — it tags, releases, and invokes purely through the GitHub and AWS APIs. Also assert head_branch == main. - High x2 (py/polynomial-redos): rewrite the italic and link regexes in markdown_to_mrkdwn with possessive quantifiers and exclusive character classes so they run in linear time on adversarial input. Adds a regression test. * Move release/announce into Deploy workflow to clear CodeQL The workflow_run-triggered release.yaml kept tripping CodeQL's privileged-context rules (untrusted-checkout, then cache-poisoning) — CodeQL distrusts any workflow_run that checks out a ref, regardless of the main-only guarantee, and there is no autofix. Fold the release job into deploy.yaml gated on `needs: deploy`. A push-to-main run is a trusted context, so checking out and running repo code with write/OIDC is safe there. This still gates on deploy success and serializes via the deploy concurrency group, and removes the separate workflow entirely. --- .github/workflows/changelog-guard.yml | 38 +++++++ .github/workflows/deploy-notify.yml | 45 -------- .github/workflows/deploy.yaml | 98 ++++++++++++++++++ CHANGELOG.md | 13 +++ README.md | 54 ++++++++-- scripts/changelog_cli.py | 52 ++++++++++ scripts/check_changelog.py | 72 +++++++++++++ scripts/sync_changelog.py | 24 +++++ slack-app-manifest.yaml | 49 +++++++++ src/release-notifier/app.py | 45 ++++++++ src/release-notifier/requirements.txt | 2 + src/shared/shared/blocks.py | 113 ++++++++++++++++++++ src/shared/shared/changelog.py | 136 +++++++++++++++++++++++++ src/slack-bot/CHANGELOG.md | 101 ++++++++++++++++++ src/slack-bot/app.py | 41 ++++++++ template.yaml | 74 ++++++++++++++ tests/release_notifier/conftest.py | 22 ++++ tests/release_notifier/test_handler.py | 67 ++++++++++++ tests/scripts/test_release_tooling.py | 70 +++++++++++++ tests/shared/test_blocks.py | 51 ++++++++++ tests/shared/test_changelog.py | 119 ++++++++++++++++++++++ tests/slack_bot/test_app_home.py | 46 +++++++++ 22 files changed, 1281 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/changelog-guard.yml delete mode 100644 .github/workflows/deploy-notify.yml create mode 100644 scripts/changelog_cli.py create mode 100644 scripts/check_changelog.py create mode 100644 scripts/sync_changelog.py create mode 100644 slack-app-manifest.yaml create mode 100644 src/release-notifier/app.py create mode 100644 src/release-notifier/requirements.txt create mode 100644 src/shared/shared/changelog.py create mode 100644 src/slack-bot/CHANGELOG.md create mode 100644 tests/release_notifier/conftest.py create mode 100644 tests/release_notifier/test_handler.py create mode 100644 tests/scripts/test_release_tooling.py create mode 100644 tests/shared/test_changelog.py create mode 100644 tests/slack_bot/test_app_home.py diff --git a/.github/workflows/changelog-guard.yml b/.github/workflows/changelog-guard.yml new file mode 100644 index 0000000..05ae16c --- /dev/null +++ b/.github/workflows/changelog-guard.yml @@ -0,0 +1,38 @@ +name: Changelog Guard + +# Repo-specific PR check (in addition to the reusable ci.yaml). Enforces that +# CHANGELOG.md drives versioning correctly: a changelog edit must be a clean +# SemVer bump above the latest tag, and the in-package copy must stay in sync. +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Validate CHANGELOG + version bump + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if git diff --name-only "$BASE_SHA" HEAD | grep -qx 'CHANGELOG.md'; then + CHANGELOG_CHANGED=true + else + CHANGELOG_CHANGED=false + fi + PREV_TAG=$(git tag -l 'v*' --sort=-v:refname | head -1) + echo "CHANGELOG changed in PR: $CHANGELOG_CHANGED | latest tag: ${PREV_TAG:-none}" + CHANGELOG_CHANGED="$CHANGELOG_CHANGED" PREV_TAG="$PREV_TAG" \ + python scripts/check_changelog.py diff --git a/.github/workflows/deploy-notify.yml b/.github/workflows/deploy-notify.yml deleted file mode 100644 index 4430985..0000000 --- a/.github/workflows/deploy-notify.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Version Bump - -# Manual only. Semantic version tags are applied deliberately (patch/minor/major); -# there is no daily auto-bump and no Slack notification. -on: - workflow_dispatch: - inputs: - bump: - description: "Version bump type" - type: choice - options: - - patch - - minor - - major - default: patch - -permissions: - contents: write - -jobs: - tag: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Compute and push next version tag - run: | - LAST_TAG=$(git tag -l 'v*' --sort=-v:refname | head -1) - LAST_TAG="${LAST_TAG:-v0.0.0}" - MAJOR=$(echo "$LAST_TAG" | sed 's/^v//' | cut -d. -f1) - MINOR=$(echo "$LAST_TAG" | sed 's/^v//' | cut -d. -f2) - PATCH=$(echo "$LAST_TAG" | sed 's/^v//' | cut -d. -f3) - - case "${{ inputs.bump }}" in - major) VERSION="v$((MAJOR + 1)).0.0" ;; - minor) VERSION="v${MAJOR}.$((MINOR + 1)).0" ;; - *) VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;; - esac - - echo "Bumping $LAST_TAG -> $VERSION" - git tag -a "$VERSION" -m "$VERSION" - git push origin "$VERSION" diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 96c2c05..4248aaf 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -20,3 +20,101 @@ jobs: secrets: deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} parameter-overrides: ${{ secrets.SAM_PARAMETER_OVERRIDES }} + + # Tag + announce a release once the deploy succeeds. This lives in the deploy + # workflow (gated on `needs: deploy`) rather than a separate workflow_run- + # triggered job on purpose: a push-to-main run is a trusted context, so + # checking out and running repo code with write/OIDC is safe here — unlike + # workflow_run, which CodeQL (rightly) flags for untrusted checkout + cache + # poisoning. Gating on `needs: deploy` still guarantees we never announce a + # version that isn't live, and the `deploy` concurrency group serializes + # releases. When the top CHANGELOG version already has a Release, this no-ops. + release: + needs: deploy + runs-on: ubuntu-latest + permissions: + contents: write # create the tag + GitHub Release + id-token: write # OIDC to assume the notifier-invoke role + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Determine release + id: rel + env: + GH_TOKEN: ${{ github.token }} + run: | + TOP=$(python scripts/changelog_cli.py top-version CHANGELOG.md) + if [ -z "$TOP" ]; then + echo "No version entry in CHANGELOG.md — nothing to release." + echo "release=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + PREV=$(git tag -l 'v*' --sort=-v:refname | head -1) + PREV="${PREV:-v0.0.0}" + KIND=$(python scripts/changelog_cli.py bump-kind CHANGELOG.md "$PREV") + + RELEASE_EXISTS=false + gh release view "v$TOP" >/dev/null 2>&1 && RELEASE_EXISTS=true + + echo "version=$TOP" >> "$GITHUB_OUTPUT" + echo "kind=$KIND" >> "$GITHUB_OUTPUT" + # Act only on a clean SemVer bump whose Release isn't published yet. + if [ "$KIND" != "none" ] && [ "$RELEASE_EXISTS" = "false" ]; then + echo "release=true" >> "$GITHUB_OUTPUT" + else + echo "release=false" >> "$GITHUB_OUTPUT" + echo "v$TOP: kind=$KIND release_exists=$RELEASE_EXISTS — no action." + fi + + - name: Build release notes + if: ${{ steps.rel.outputs.release == 'true' }} + run: | + python scripts/changelog_cli.py payload CHANGELOG.md "${{ steps.rel.outputs.version }}" > payload.json + python -c "import json; print(json.load(open('payload.json'))['notes'])" > notes.md + + # Announce BEFORE publishing the Release: the Release is the durable "done" + # marker (the step above skips once it exists), so announcing first keeps + # this retryable. Minor/major only, and only once the invoke-role variable + # has been bootstrapped (see README). + - name: Configure AWS credentials + if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }} + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN }} + aws-region: us-east-1 + + - name: Announce in Slack + if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }} + run: | + aws lambda invoke \ + --function-name afterhours-release-notifier \ + --cli-binary-format raw-in-base64-out \ + --payload file://payload.json \ + --output json response.json > invoke-meta.json + # aws lambda invoke only emits a FunctionError key when the handler errored. + if grep -q '"FunctionError"' invoke-meta.json; then + echo "::error::release-notifier returned an error"; cat response.json; exit 1 + fi + echo "Announced v${{ steps.rel.outputs.version }}." + + - name: Warn if announcement skipped (not bootstrapped) + if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN == '' }} + run: echo "::warning::RELEASE_NOTIFY_INVOKE_ROLE_ARN is unset — tagging + releasing but not announcing. Set the repo variable from the stack output." + + - name: Publish GitHub Release + if: ${{ steps.rel.outputs.release == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + # gh creates the tag at the deployed commit and the Release together. + gh release create "v${{ steps.rel.outputs.version }}" \ + --repo "${{ github.repository }}" \ + --title "v${{ steps.rel.outputs.version }}" \ + --notes-file notes.md \ + --target "${{ github.sha }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index cb66692..0f9114b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,22 @@ after-hours on-call phone duty. Newest first, in plain language. Versions are `MAJOR.MINOR.PATCH`: a **minor** bump adds a new feature, a **patch** is a fix or tidy-up, and a **major** would be a big change to how things work. +A few older entries cover two versions at once (e.g. `v1.9.0 / v1.9.1`) — that's +fine and still supported. --- +## v1.10.0 — June 11, 2026 + +**The bot now tells you what's new.** Two things: + +- When a noticeable update ships (a new feature or a bigger change), the bot + posts a short "What's New" note right in the on-call channel — so you hear + about changes without having to go looking. Small fixes stay quiet. +- The bot now has an **About** page. Click the bot's name in Slack and open its + **Home** tab to see what it does, the full list of `/oncall` commands, and the + latest "What's New". It always shows the current version. + ## v1.9.2 — June 1, 2026 **Setup-guide fix.** Corrected the install instructions so secrets (the Slack and diff --git a/README.md b/README.md index a4648a4..5bbf386 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ A recurring weekly schedule assigns employees to after-hours phone duty. Weekend The weekly schedule post is updated live when shifts change, and the previous week's post is automatically deleted when the new one goes out. +The bot also has an **About** page: open the bot in Slack and click its **Home** tab to see what it does, the full command list, and the latest "What's New" (see [Releases & Versioning](#releases--versioning)). + ## Slack Commands | Command | Description | @@ -62,17 +64,20 @@ Dates accept: `today`, `tomorrow`, `monday`-`sunday`, `4/5`, `2026-04-05` | `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email | | `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX | | `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift | +| `afterhours-release-notifier` | Invoked by the Deploy workflow's release job on minor/major releases | Posts a "What's New" announcement to the shift channel | ### Project Layout ``` src/ - slack-bot/ Slack Bolt Lambda (handler + app) - weekly-post/ Monday schedule + pay post - roster-sync/ Daily 3CX roster sync - ring-scheduler/ 3CX queue routing updates - shared/ Lambda Layer (schedule, blocks, 3CX client, secrets) -tests/ pytest suite (mirrors src/, one dir per Lambda + shared) + slack-bot/ Slack Bolt Lambda (handler + app); ships CHANGELOG.md for App Home + weekly-post/ Monday schedule + pay post + roster-sync/ Daily 3CX roster sync + ring-scheduler/ 3CX queue routing updates + release-notifier/ Posts release announcements to Slack + shared/ Lambda Layer (schedule, blocks, changelog, 3CX client, secrets) +scripts/ changelog CLI + CI guard + in-package copy sync +tests/ pytest suite (mirrors src/, one dir per Lambda + shared) ``` ### DynamoDB Schema @@ -114,6 +119,43 @@ sam build sam deploy ``` +## Releases & Versioning + +The bot is versioned with SemVer, driven entirely by **`CHANGELOG.md`** — it is +the single source of truth for both the version number and the human-readable +notes. There is no separate tagging tool. + +**To cut a release**, in your feature PR add a new `## vX.Y.Z — Month D, YYYY` +section at the top of `CHANGELOG.md` (plain language, written for on-call staff), +bumping per SemVer, then run `python scripts/sync_changelog.py` to update the +in-package copy. The `Changelog Guard` PR check enforces that the bump is a clean +single SemVer step above the latest tag and that the two copies match. + +On the **deploy-then-merge** path, once the merge's Deploy succeeds, the Deploy +workflow's `release` job (`needs: deploy`) tags the new version, publishes a +GitHub Release with the notes, and — for **minor and major** bumps only (patches +stay silent) — invokes `afterhours-release-notifier` to post a "What's New" +message in the shift channel. The **App Home** tab ("About" page on the bot) +always shows the current version's notes, read from the CHANGELOG that ships in +the slack-bot package. + +> The release job lives inside the Deploy workflow (gated on `needs: deploy`) +> rather than a separate `workflow_run`-triggered workflow. A push-to-main run is +> a trusted context, so checking out and running repo code with write/OIDC is safe +> — whereas `workflow_run` is flagged by CodeQL for untrusted checkout. Gating on +> `needs: deploy` still guarantees we never announce a version that isn't live. + +**One-time setup (per environment):** after the first deploy creates the +`ReleaseNotifyInvokeRole`, copy its ARN from the `ReleaseNotifyInvokeRoleArn` stack +output into the repo **variable** `RELEASE_NOTIFY_INVOKE_ROLE_ARN` (Settings → +Secrets and variables → Actions → Variables). Until it's set, releases still tag +and publish but skip the Slack announcement (with a warning). + +> **Convention note (deliberate deviation).** The Sea Haven handbook says internal +> SAM stacks generally need no versioning and that tags are applied manually. This +> bot is versioned by owner choice (it has staff-facing release notes) and tagged +> automatically by the Deploy workflow's release job. This is intentional — not drift. + ## Testing Unit tests use `pytest` with all external boundaries mocked — DynamoDB / SES / diff --git a/scripts/changelog_cli.py b/scripts/changelog_cli.py new file mode 100644 index 0000000..381d7db --- /dev/null +++ b/scripts/changelog_cli.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Thin CLI over ``shared.changelog`` for the release workflow. + +Keeps all changelog parsing in one tested module instead of inline shell/Python +in the workflow. Reads the changelog file given as an argument. + +Usage: + changelog_cli.py top-version # newest entry's version + changelog_cli.py bump-kind # major|minor|patch|none vs + changelog_cli.py payload # JSON {version, notes, date_label} +""" + +import json +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src" / "shared")) + +from shared.changelog import bump_kind, entry_for, top_version # noqa: E402 + + +def main(argv: list[str]) -> int: + if len(argv) < 3: + sys.exit(__doc__) + cmd, path = argv[1], argv[2] + text = pathlib.Path(path).read_text() + + if cmd == "top-version": + sys.stdout.write(top_version(text) or "") + elif cmd == "bump-kind": + prev = argv[3].lstrip("v") + top = top_version(text) + sys.stdout.write((bump_kind(top, prev) or "none") if top else "none") + elif cmd == "payload": + version = argv[3].lstrip("v") + entry = entry_for(text, version) + sys.stdout.write( + json.dumps( + { + "version": version, + "notes": entry.body if entry else "", + "date_label": entry.date_label if entry else "", + } + ) + ) + else: + sys.exit(f"unknown command: {cmd}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/check_changelog.py b/scripts/check_changelog.py new file mode 100644 index 0000000..c1825ee --- /dev/null +++ b/scripts/check_changelog.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""CI guard: validate CHANGELOG.md versioning and the in-package copy. + +Run on pull requests. Two checks: + +1. If CHANGELOG.md changed in the PR, its top version must be a clean + single-step SemVer bump above the latest ``v*`` tag. (Non-changing PRs — + dependabot bumps, docs — are not version-checked, so they don't release.) +2. ``src/slack-bot/CHANGELOG.md`` (the copy that ships with the bot and feeds the + App Home "What's New" tab) must match the canonical root CHANGELOG.md. + +The workflow passes context via env: ``PREV_TAG`` (latest tag) and +``CHANGELOG_CHANGED`` ("true"/"false"). Run locally it assumes the changelog +changed so the bump is validated. +""" + +import os +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src" / "shared")) + +from shared.changelog import bump_kind, top_version # noqa: E402 + +ROOT = pathlib.Path(__file__).resolve().parents[1] +PKG_COPY = ROOT / "src" / "slack-bot" / "CHANGELOG.md" + + +def evaluate( + top: str | None, prev_tag: str, changelog_changed: bool, copies_match: bool +) -> list[str]: + """Return a list of problems (empty == pass). Pure, for unit testing.""" + problems = [] + if not copies_match: + problems.append( + "src/slack-bot/CHANGELOG.md is out of sync with CHANGELOG.md — " + "run scripts/sync_changelog.py" + ) + if changelog_changed: + if top is None: + problems.append("CHANGELOG.md changed but has no version entry at the top") + else: + prev = (prev_tag or "v0.0.0").lstrip("v") + if bump_kind(top, prev) is None: + problems.append( + f"top version v{top} is not a clean single-step SemVer bump " + f"above the latest tag v{prev} (expected one of " + f"inc-major / inc-minor / inc-patch)" + ) + return problems + + +def main() -> int: + root_text = (ROOT / "CHANGELOG.md").read_text() + copies_match = PKG_COPY.exists() and PKG_COPY.read_text() == root_text + + problems = evaluate( + top=top_version(root_text), + prev_tag=os.environ.get("PREV_TAG", ""), + changelog_changed=os.environ.get("CHANGELOG_CHANGED", "true") == "true", + copies_match=copies_match, + ) + if problems: + for problem in problems: + print(f"::error::{problem}") + return 1 + print("CHANGELOG guard passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sync_changelog.py b/scripts/sync_changelog.py new file mode 100644 index 0000000..37eb5d8 --- /dev/null +++ b/scripts/sync_changelog.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Copy the canonical root CHANGELOG.md into the slack-bot package. + +The bot's App Home "What's New" tab reads CHANGELOG.md from its own deployment +package ($LAMBDA_TASK_ROOT), so a copy must live under src/slack-bot/ (the +function's CodeUri). The root file is the single source of truth; run this after +editing it. CI's check_changelog.py fails if the two drift. +""" + +import pathlib +import shutil + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def main() -> None: + src = ROOT / "CHANGELOG.md" + dst = ROOT / "src" / "slack-bot" / "CHANGELOG.md" + shutil.copyfile(src, dst) + print(f"Synced {src} -> {dst}") + + +if __name__ == "__main__": + main() diff --git a/slack-app-manifest.yaml b/slack-app-manifest.yaml new file mode 100644 index 0000000..afd17d8 --- /dev/null +++ b/slack-app-manifest.yaml @@ -0,0 +1,49 @@ +# Slack app manifest — After-Hours Shift Manager +# +# Version-controlled source of truth for the Slack app configuration. The app +# predates this file, so before applying it wholesale via the Slack manifest API, +# reconcile it against the live app config (App settings → App Manifest) so no +# existing scope or setting is dropped. +# +# The ONLY delta this release introduces is the App Home tab: +# - settings.event_subscriptions.bot_events: + app_home_opened +# - features.app_home.home_tab_enabled: true +# Neither needs a new OAuth scope, so no reinstall is required (see README → +# "Releases & versioning"). Replace with the SlackBotApiUrl stack output. +display_information: + name: After-Hours Shift Manager + description: Manage after-hours on-call phone duty from Slack. + +features: + bot_user: + display_name: oncall + always_online: true + slash_commands: + - command: /oncall + url: + description: Manage after-hours on-call shifts + usage_hint: "[next|pick|drop|swap|register|pay|rate|roster|help] …" + should_escape: false + app_home: + home_tab_enabled: true + messages_tab_enabled: true + messages_tab_read_only_enabled: false + +oauth_config: + scopes: + bot: + - commands + - chat:write + - im:write + - users:read + +settings: + event_subscriptions: + request_url: + bot_events: + - app_home_opened + interactivity: + is_enabled: true + request_url: + org_deploy_enabled: false + socket_mode_enabled: false diff --git a/src/release-notifier/app.py b/src/release-notifier/app.py new file mode 100644 index 0000000..d54d4d7 --- /dev/null +++ b/src/release-notifier/app.py @@ -0,0 +1,45 @@ +"""Lambda handler — announces a new release to the shift channel. + +Invoked by the release workflow (``.github/workflows/release.yaml``) once a +minor or major version has been tagged *and* the new code has deployed +successfully. The event carries the version and notes already extracted from +CHANGELOG.md by the workflow, so this function never reads the changelog file +itself (it ships only in the slack-bot package, not here). + +Event shape (the frozen contract between release.yaml and this function): + {"version": "1.10.0", "notes": "", "date_label": "June 11, 2026"} +""" + +import logging +import os + +from slack_sdk import WebClient + +from shared.blocks import build_release_announcement_blocks +from shared.secrets import get_secret + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def handler(event, context): + event = event or {} + version = event.get("version") + notes = event.get("notes") + date_label = event.get("date_label", "") + + if not version or not notes: + raise ValueError("event requires non-empty 'version' and 'notes'") + + bot_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"]) + channel_id = os.environ["SHIFT_CHANNEL"] + slack = WebClient(token=bot_token) + + blocks = build_release_announcement_blocks(version, notes, date_label) + result = slack.chat_postMessage( + channel=channel_id, + blocks=blocks, + text=f"What's New — v{version}", + ) + logger.info("Announced v%s to %s (ts=%s)", version, channel_id, result["ts"]) + return {"announced": True, "version": version, "ts": result["ts"]} diff --git a/src/release-notifier/requirements.txt b/src/release-notifier/requirements.txt new file mode 100644 index 0000000..3246b4c --- /dev/null +++ b/src/release-notifier/requirements.txt @@ -0,0 +1,2 @@ +slack_sdk>=3.42.0,<4.0 +boto3>=1.43.27 diff --git a/src/shared/shared/blocks.py b/src/shared/shared/blocks.py index 6edd5e8..a0e5881 100644 --- a/src/shared/shared/blocks.py +++ b/src/shared/shared/blocks.py @@ -1,5 +1,6 @@ """Slack Block Kit message builders for shift schedule display.""" +import re from datetime import datetime, timedelta from zoneinfo import ZoneInfo @@ -7,6 +8,62 @@ from shared.schedule import WEEKEND_DAYS EASTERN = ZoneInfo("America/New_York") +# Slack section text hard limit is 3000 chars; leave headroom for the truncation note. +_SECTION_LIMIT = 2900 + + +def markdown_to_mrkdwn(text: str) -> str: + """Convert the CHANGELOG's standard Markdown to Slack ``mrkdwn``. + + The changelog is authored in GitHub-flavoured Markdown (``**bold**``, + ``*italic*``, ``[text](url)``, ``- `` bullets), but Slack uses a different + dialect (``*bold*``, ``_italic_``, ````, ``•`` bullets). Bold is + swapped via a placeholder first so the italic pass cannot mangle it. + + The italic and link patterns use possessive quantifiers (``++``) and exclusive + character classes so they run in linear time on adversarial input — no + catastrophic backtracking (py/polynomial-redos). + """ + text = re.sub(r"\*\*([^\n]+?)\*\*", "\x00\\1\x00", text) # bold -> placeholder + text = re.sub(r"\*([^*\n]++)\*", r"_\1_", text) # italic (single asterisks) + text = text.replace("\x00", "*") # placeholder -> Slack bold + text = re.sub(r"\[([^\]]++)\]\(([^)\n]++)\)", r"<\2|\1>", text) # links + text = re.sub(r"(?m)^(\s*)[-*]\s+", r"\1• ", text) # bullets + return text + + +def build_release_announcement_blocks( + version: str, notes: str, date_label: str = "" +) -> list[dict]: + """Build the channel post announcing a new minor/major release. + + Args: + version: SemVer string without the ``v`` prefix, e.g. ``"1.10.0"``. + notes: the changelog entry body in Markdown. + date_label: human date, e.g. ``"June 11, 2026"`` (optional). + """ + body = markdown_to_mrkdwn(notes.strip()) + if len(body) > _SECTION_LIMIT: + body = ( + body[:_SECTION_LIMIT].rstrip() + "\n\n_…see the full changelog for more._" + ) + + blocks: list[dict] = [ + { + "type": "header", + "text": {"type": "plain_text", "text": f"What's New — v{version}"}, + } + ] + if date_label: + blocks.append( + { + "type": "context", + "elements": [{"type": "mrkdwn", "text": f"Released {date_label}"}], + } + ) + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": body}}) + return blocks + SHIFT_LABELS = { "day": "Day (8am–5pm)", @@ -228,6 +285,62 @@ def build_help_blocks(is_admin: bool = False) -> list[dict]: return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] +def build_home_view( + version: str | None = None, notes: str = "", date_label: str = "" +) -> dict: + """Build the App Home tab: what the bot does, the commands, and what's new. + + ``version``/``notes``/``date_label`` come from the newest CHANGELOG entry; when + absent (e.g. the changelog could not be read) the "What's New" section is + simply omitted. + """ + blocks: list[dict] = [ + { + "type": "header", + "text": {"type": "plain_text", "text": "After-Hours Shift Manager"}, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + "I manage the after-hours on-call phone duty. Use `/oncall` in " + "any channel to see the schedule, pick up or drop shifts, and " + "swap with teammates — changes update the phone routing " + "automatically." + ), + }, + }, + *build_help_blocks(is_admin=False), + ] + + if version: + blocks.append({"type": "divider"}) + blocks.append( + { + "type": "header", + "text": {"type": "plain_text", "text": f"What's New in v{version}"}, + } + ) + if date_label: + blocks.append( + { + "type": "context", + "elements": [{"type": "mrkdwn", "text": f"Released {date_label}"}], + } + ) + if notes: + body = markdown_to_mrkdwn(notes.strip()) + if len(body) > _SECTION_LIMIT: + body = ( + body[:_SECTION_LIMIT].rstrip() + + "\n\n_…see the full changelog for more._" + ) + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": body}}) + + return {"type": "home", "blocks": blocks} + + def build_pay_summary_blocks( week_label: str, pay_breakdown: list[dict], total_by_person: dict[str, dict] ) -> list[dict]: diff --git a/src/shared/shared/changelog.py b/src/shared/shared/changelog.py new file mode 100644 index 0000000..339cf28 --- /dev/null +++ b/src/shared/shared/changelog.py @@ -0,0 +1,136 @@ +"""Parse CHANGELOG.md — the single source of truth for version and release notes. + +These are pure, text-in helpers shared by three consumers so the parsing rules +live in exactly one place: + +* the Slack App Home tab — renders the newest entry ("What's New in vX.Y.Z"), +* the release workflow — pulls the notes for the tag it is about to create, +* the CI changelog guard — validates the top version is a clean SemVer bump. + +The parser tolerates the file's leading preamble (prose before the first ``##`` +header), date-only historical headers like ``## May 1, 2026 — …`` (no version), +and legacy combined entries like ``## v1.9.0 / v1.9.1 — June 1, 2026``. +""" + +import re +from dataclasses import dataclass + +# Headers are level-2 ATX (``## ...``). Capture the title text only. +_HEADER_RE = re.compile(r"^##\s+(?P.+?)\s*$", re.MULTILINE) +# A dotted MAJOR.MINOR.PATCH triple, with an optional leading ``v``. +_VERSION_RE = re.compile(r"v?(\d+)\.(\d+)\.(\d+)") + + +@dataclass(frozen=True) +class Entry: + """One changelog section, delimited by ``##`` headers.""" + + versions: tuple[str, ...] # e.g. ("1.9.0", "1.9.1"); empty for date-only headers + title: str # header text, minus the leading "## " + date_label: str # text after the em dash, e.g. "June 1, 2026" + body: str # markdown between this header and the next "## " + + @property + def version(self) -> str | None: + """Highest SemVer in the header, or None for a date-only header. + + Combined entries (``v1.9.0 / v1.9.1``) report the higher version, which is + what "What's New" should surface. + """ + if not self.versions: + return None + return max(self.versions, key=_version_key) + + +def _version_key(version: str) -> tuple[int, int, int]: + match = _VERSION_RE.search(version) + if match is None: + raise ValueError(f"not a semver: {version!r}") + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def _normalize(version: str) -> str: + return version.lstrip("v") + + +def _strip_rules(body: str) -> str: + """Drop ``---`` thematic-break lines from the body's edges. + + The file uses horizontal rules to separate eras; they delimit content but are + not part of any entry's notes, so they should not leak into a rendered post. + """ + lines = body.splitlines() + while lines and re.fullmatch(r"-{3,}", lines[0].strip()): + lines.pop(0) + while lines and re.fullmatch(r"-{3,}", lines[-1].strip()): + lines.pop() + return "\n".join(lines).strip() + + +def parse_changelog(text: str) -> list[Entry]: + """Split the changelog into entries, newest first (file order is preserved).""" + entries: list[Entry] = [] + headers = list(_HEADER_RE.finditer(text)) + for i, match in enumerate(headers): + title = match.group("title").strip() + body_start = match.end() + body_end = headers[i + 1].start() if i + 1 < len(headers) else len(text) + body = _strip_rules(text[body_start:body_end].strip()) + versions = tuple(f"{a}.{b}.{c}" for a, b, c in _VERSION_RE.findall(title)) + date_label = title.split("—")[-1].strip() if "—" in title else "" + entries.append( + Entry(versions=versions, title=title, date_label=date_label, body=body) + ) + return entries + + +def version_entries(text: str) -> list[Entry]: + """Only the entries that carry at least one version (skips date-only headers).""" + return [entry for entry in parse_changelog(text) if entry.versions] + + +def latest_entry(text: str) -> Entry | None: + """The newest version-bearing entry, or None if the file has no versions yet.""" + for entry in parse_changelog(text): + if entry.versions: + return entry + return None + + +def top_version(text: str) -> str | None: + """The version of the newest entry — what a new release tags against.""" + entry = latest_entry(text) + return entry.version if entry else None + + +def entry_for(text: str, version: str) -> Entry | None: + """The entry whose header includes ``version`` (``v`` prefix optional).""" + target = _normalize(version) + for entry in parse_changelog(text): + if any(_normalize(v) == target for v in entry.versions): + return entry + return None + + +def bump_kind(new: str, prev: str) -> str | None: + """Classify ``new`` relative to ``prev`` as a single clean SemVer step. + + Returns ``"major"``, ``"minor"``, or ``"patch"`` for an exact one-step + increment, or None for anything else (skip, multi-step, or downgrade). Note a + minor bump resets patch to 0 (``1.9.2 -> 1.10.0``), so this compares whole + tuples rather than counting changed components. + """ + nmaj, nmin, npat = _version_key(new) + pmaj, pmin, ppat = _version_key(prev) + if (nmaj, nmin, npat) == (pmaj + 1, 0, 0): + return "major" + if (nmaj, nmin, npat) == (pmaj, pmin + 1, 0): + return "minor" + if (nmaj, nmin, npat) == (pmaj, pmin, ppat + 1): + return "patch" + return None + + +def is_valid_bump(new: str, prev: str) -> bool: + """True iff ``new`` is exactly one SemVer step above ``prev``.""" + return bump_kind(new, prev) is not None diff --git a/src/slack-bot/CHANGELOG.md b/src/slack-bot/CHANGELOG.md new file mode 100644 index 0000000..0f9114b --- /dev/null +++ b/src/slack-bot/CHANGELOG.md @@ -0,0 +1,101 @@ +# Changelog + +What's changed in the **After-Hours Shift Manager** — the Slack bot that runs our +after-hours on-call phone duty. Newest first, in plain language. + +Versions are `MAJOR.MINOR.PATCH`: a **minor** bump adds a new feature, a **patch** +is a fix or tidy-up, and a **major** would be a big change to how things work. +A few older entries cover two versions at once (e.g. `v1.9.0 / v1.9.1`) — that's +fine and still supported. + +--- + +## v1.10.0 — June 11, 2026 + +**The bot now tells you what's new.** Two things: + +- When a noticeable update ships (a new feature or a bigger change), the bot + posts a short "What's New" note right in the on-call channel — so you hear + about changes without having to go looking. Small fixes stay quiet. +- The bot now has an **About** page. Click the bot's name in Slack and open its + **Home** tab to see what it does, the full list of `/oncall` commands, and the + latest "What's New". It always shows the current version. + +## v1.9.2 — June 1, 2026 + +**Setup-guide fix.** Corrected the install instructions so secrets (the Slack and +phone-system passwords) are stored in the right, secure place. No change to the +bot itself — this only affects someone setting it up from scratch. + +## v1.9.0 / v1.9.1 — June 1, 2026 + +**You can no longer drop a shift at the last minute.** If a shift starts within +the next 24 hours, you can't just drop it and walk away — you have to hand it to +someone specific (a swap they accept), or ask an admin. This stops the phones +from being left uncovered with no notice. *(v1.9.1 was a routine update of +behind-the-scenes software libraries.)* + +## v1.8.0 — June 1, 2026 + +**Swaps now need the other person to say yes.** Before, `/oncall swap` instantly +dumped your shift on someone else. Now they get a Slack message with **Accept** +and **Decline** buttons, and the shift only moves if they accept. Until then it +stays yours. If they don't respond before the shift starts, the request quietly +expires. + +## v1.7.19 — June 1, 2026 + +**Quality safety net (no visible change).** Added an automated test suite that +checks the bot's behavior on every change, so bugs get caught before they ship. +You won't notice anything different day-to-day — it just makes future updates +safer. + +--- + +## May 2026 — Phone-system automation & a big fix + +- **Live phone routing follows the schedule.** The system that decides which + phone rings after hours now reads directly from the on-call schedule, so + pickups, drops, and swaps take effect automatically. +- **Fixed a release that had broken the whole bot.** A packaging mistake stopped + all of the bot's functions from running; this was found and fixed. +- Ongoing routine updates to behind-the-scenes software libraries. +- **Behind the scenes:** moved to an automated build-and-release pipeline, added + automatic code checks and formatting, and turned on automated dependency and + code-review tooling. None of this changes how you use the bot. + +## May 1, 2026 — Reliability & notifications + +- **No more double-booking.** Fixed a timing bug where two people could grab the + same open shift at once. +- **Shift changes are announced.** When someone picks up, drops, or swaps a + shift, a note is posted to the team channel. +- **Fixed a confusing error** that showed up when picking a shift even though the + pickup had actually worked. + +## April 8, 2026 — Schedule & pay polish + +- The weekly schedule now always starts on **Monday** (it used to start from + today). +- Tidied up the weekly pay report (recipient and wording). + +## April 7, 2026 — Scheduling & pay + +- **Two-week schedule view** instead of just the current week. +- **Weekly pay totals** for on-call shifts, with a configurable flat rate. +- **Per-person pay rates** and an `/oncall rate` command to manage them from + Slack. +- **Weekend day shifts** — weekends are split into a daytime and an overnight + shift. +- **Automatic employee roster sync** — the bot pulls the staff list from the + phone system each day, so the roster stays current on its own. +- **Weekly "Bonus Pay Summary"** emailed to payroll (and sent as a Slack DM to + the admin) every Monday. + +## April 3, 2026 — Launch 🎉 + +The first version of the After-Hours Shift Manager: + +- See the on-call schedule in Slack with `/oncall`. +- **Pick up** an open shift and **drop** a shift you're covering. +- Link your Slack account to your phone extension with `/oncall register`. diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index 5428995..7b07309 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -6,6 +6,7 @@ can be unit-tested directly. ``schedule`` (a ``ShiftSchedule``) and is a thin wiring layer that registers the Bolt routes and delegates to them. """ +import functools import logging import os import re @@ -16,6 +17,7 @@ from slack_bolt import App from shared.blocks import ( build_help_blocks, + build_home_view, build_pay_summary_blocks, build_roster_blocks, build_shift_change_message, @@ -23,6 +25,7 @@ from shared.blocks import ( build_swap_resolved_blocks, build_week_schedule, ) +from shared.changelog import latest_entry from shared.ring_scheduler import update_queue_routing from shared.schedule import ( FALLBACK_EXTENSION, @@ -927,6 +930,37 @@ def _handle_admin(respond, schedule, user_id, text, is_admin, client, schedule_c respond(text=f"Unknown admin command: `{subcmd}`. Try `/oncall help`.") +# ── App Home ──────────────────────────────────────────────────────────── + + +@functools.lru_cache(maxsize=1) +def _changelog_text() -> str: + """Read the CHANGELOG shipped in this function's package. + + Lazy (never at import) and tolerant of a missing file, so the App Home tab + degrades to "no What's New section" rather than erroring. The copy lives at + ``$LAMBDA_TASK_ROOT/CHANGELOG.md`` (synced from the repo root). + """ + path = os.path.join(os.environ.get("LAMBDA_TASK_ROOT", "."), "CHANGELOG.md") + try: + with open(path, encoding="utf-8") as fh: + return fh.read() + except OSError: + logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", path) + return "" + + +def publish_home(client, user_id: str, changelog_text: str) -> None: + """Render and publish the App Home view for ``user_id``.""" + entry = latest_entry(changelog_text) + view = build_home_view( + version=entry.version if entry else None, + notes=entry.body if entry else "", + date_label=entry.date_label if entry else "", + ) + client.views_publish(user_id=user_id, view=view) + + # ── App factory ───────────────────────────────────────────────────────── @@ -960,4 +994,11 @@ def create_app( ack() handle_swap_decline(body, respond, client, schedule, schedule_channel) + @app.event("app_home_opened") + def handle_app_home_opened(event, client): + # Fires for the Messages tab too; only (re)publish the Home tab. + if event.get("tab") != "home": + return + publish_home(client, event["user"], _changelog_text()) + return app diff --git a/template.yaml b/template.yaml index 172e603..fa0b5d4 100644 --- a/template.yaml +++ b/template.yaml @@ -249,6 +249,69 @@ Resources: Description: "Update 3CX queue at 5pm EDT weekends" Enabled: true + # --- Release Notifier (invoked by release.yaml on minor/major releases) --- + ReleaseNotifierFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: afterhours-release-notifier + Handler: app.handler + CodeUri: src/release-notifier/ + Layers: + - !Ref SharedLayer + Environment: + Variables: + SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token + SHIFT_CHANNEL: !Ref ShiftChannel + TZ: !Ref Timezone + Policies: + # Least privilege: only the Slack bot token, not the whole namespace. + - Statement: + - Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: + - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/slack-bot-token-*" + + # GitHub-OIDC role assumed by release.yaml to invoke the notifier. Auto-named + # (no RoleName) so the deploy's CAPABILITY_IAM is sufficient — cd-sam does not + # pass CAPABILITY_NAMED_IAM. Trust + permission are scoped to the minimum: this + # repo's main ref + release workflow, and InvokeFunction on the notifier alone. + # Its ARN is surfaced as a stack output and set once as the + # RELEASE_NOTIFY_INVOKE_ROLE_ARN repo variable (see README). + # + # The permissions boundary is REQUIRED, not optional: the scoped + # github-cfn-execution-role's IAM policy gates iam:CreateRole/PutRolePolicy on + # the role carrying exactly this boundary, so the CI deploy is denied without + # it. The boundary itself permits lambda:InvokeFunction (Sid LambdaInvoke), so + # it does not restrict this role's one job. + ReleaseNotifyInvokeRole: + Type: AWS::IAM::Role + Properties: + PermissionsBoundary: arn:aws:iam::328440206208:policy/seahaven-lambda-execution-boundary + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Federated: !Sub "arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com" + Action: sts:AssumeRoleWithWebIdentity + Condition: + StringEquals: + token.actions.githubusercontent.com:aud: sts.amazonaws.com + StringLike: + token.actions.githubusercontent.com:sub: "repo:Sea-Haven-Industries/afterhours-shift-manager:ref:refs/heads/main" + # Defense-in-depth: only the release workflow may assume this role, + # not any workflow running on main. + token.actions.githubusercontent.com:job_workflow_ref: "Sea-Haven-Industries/afterhours-shift-manager/.github/workflows/release.yaml@refs/heads/main" + Policies: + - PolicyName: invoke-release-notifier + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: lambda:InvokeFunction + Resource: !GetAtt ReleaseNotifierFunction.Arn + # --- CloudWatch Log Groups (explicit 60-day retention) --- SlackBotLogGroup: Type: AWS::Logs::LogGroup @@ -274,6 +337,12 @@ Resources: LogGroupName: !Sub "/aws/lambda/${RingSchedulerFunction}" RetentionInDays: 60 + ReleaseNotifierLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/lambda/${ReleaseNotifierFunction}" + RetentionInDays: 60 + Outputs: SlackBotApiUrl: Description: URL for Slack app Request URL configuration @@ -288,3 +357,8 @@ Outputs: Value: !GetAtt RosterSyncFunction.Arn RingSchedulerFunctionArn: Value: !GetAtt RingSchedulerFunction.Arn + ReleaseNotifierFunctionArn: + Value: !GetAtt ReleaseNotifierFunction.Arn + ReleaseNotifyInvokeRoleArn: + Description: Set this as the RELEASE_NOTIFY_INVOKE_ROLE_ARN repo variable for release.yaml + Value: !GetAtt ReleaseNotifyInvokeRole.Arn diff --git a/tests/release_notifier/conftest.py b/tests/release_notifier/conftest.py new file mode 100644 index 0000000..4823fb5 --- /dev/null +++ b/tests/release_notifier/conftest.py @@ -0,0 +1,22 @@ +"""Load src/release-notifier/app.py under a unique module name.""" + +import importlib.util +import pathlib +import sys + +import pytest + +_ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def _load(name, relpath): + spec = importlib.util.spec_from_file_location(name, _ROOT / relpath) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def notifier_app(): + return _load("release_notifier_app", "src/release-notifier/app.py") diff --git a/tests/release_notifier/test_handler.py b/tests/release_notifier/test_handler.py new file mode 100644 index 0000000..c24c6b1 --- /dev/null +++ b/tests/release_notifier/test_handler.py @@ -0,0 +1,67 @@ +"""Tests for the release-notifier Lambda handler.""" + +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def slack(notifier_app, monkeypatch): + """Fake Slack WebClient; chat_postMessage returns a message ts.""" + fake = MagicMock(name="slack") + fake.chat_postMessage.return_value = {"ts": "111.222"} + monkeypatch.setattr(notifier_app, "WebClient", MagicMock(return_value=fake)) + monkeypatch.setattr(notifier_app, "get_secret", lambda _id: "xoxb-test") + return fake + + +@pytest.fixture +def env(monkeypatch): + monkeypatch.setenv( + "SLACK_BOT_TOKEN_SECRET", "afterhours-shift-manager/slack-bot-token" + ) + monkeypatch.setenv("SHIFT_CHANNEL", "C_RELEASES") + + +def test_posts_announcement_to_channel(notifier_app, slack, env): + result = notifier_app.handler( + { + "version": "1.10.0", + "notes": "**Release notes** now self-announce.", + "date_label": "June 11, 2026", + }, + None, + ) + + assert result == {"announced": True, "version": "1.10.0", "ts": "111.222"} + slack.chat_postMessage.assert_called_once() + kwargs = slack.chat_postMessage.call_args.kwargs + assert kwargs["channel"] == "C_RELEASES" + assert kwargs["text"] == "What's New — v1.10.0" + # Markdown was converted to Slack mrkdwn in the rendered blocks. + rendered = str(kwargs["blocks"]) + assert "*Release notes*" in rendered + + +def test_uses_the_slack_bot_token_secret(notifier_app, slack, env, monkeypatch): + seen = {} + monkeypatch.setattr( + notifier_app, "get_secret", lambda sid: seen.setdefault("id", sid) or "xoxb" + ) + notifier_app.handler({"version": "2.0.0", "notes": "Big."}, None) + assert seen["id"] == "afterhours-shift-manager/slack-bot-token" + + +@pytest.mark.parametrize( + "event", + [ + {}, + {"version": "1.10.0"}, # missing notes + {"notes": "x"}, # missing version + {"version": "", "notes": "x"}, # empty version + ], +) +def test_rejects_incomplete_event(notifier_app, slack, env, event): + with pytest.raises(ValueError): + notifier_app.handler(event, None) + slack.chat_postMessage.assert_not_called() diff --git a/tests/scripts/test_release_tooling.py b/tests/scripts/test_release_tooling.py new file mode 100644 index 0000000..9f20a8a --- /dev/null +++ b/tests/scripts/test_release_tooling.py @@ -0,0 +1,70 @@ +"""Tests for the release tooling scripts (CI guard + changelog CLI).""" + +import importlib.util +import json +import pathlib +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def _load(name, relpath): + spec = importlib.util.spec_from_file_location(name, ROOT / relpath) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +check = _load("check_changelog", "scripts/check_changelog.py") +cli = _load("changelog_cli", "scripts/changelog_cli.py") + + +class TestGuardEvaluate: + def test_clean_minor_bump_passes(self): + assert check.evaluate("1.10.0", "v1.9.2", True, True) == [] + + def test_bad_bump_flagged(self): + problems = check.evaluate("1.11.0", "v1.9.2", True, True) # skips a minor + assert problems and "clean single-step" in problems[0] + + def test_unchanged_changelog_is_not_version_checked(self): + # A docs/dependabot PR (no CHANGELOG edit) never trips the bump check. + assert check.evaluate("5.0.0", "v1.9.2", False, True) == [] + + def test_copy_drift_flagged_even_when_unchanged(self): + problems = check.evaluate("1.9.2", "v1.9.2", False, False) + assert any("out of sync" in p for p in problems) + + def test_missing_top_version_flagged_when_changed(self): + problems = check.evaluate(None, "v1.9.2", True, True) + assert any("no version entry" in p for p in problems) + + def test_first_release_from_no_tags(self): + assert check.evaluate("0.1.0", "", True, True) == [] + + +class TestChangelogCli: + SAMPLE = "## v1.10.0 — June 11, 2026\n\n**Self-announcing** releases.\n" + + def test_top_version(self, tmp_path, capsys): + f = tmp_path / "CHANGELOG.md" + f.write_text(self.SAMPLE) + cli.main(["x", "top-version", str(f)]) + assert capsys.readouterr().out == "1.10.0" + + def test_bump_kind(self, tmp_path, capsys): + f = tmp_path / "CHANGELOG.md" + f.write_text(self.SAMPLE) + cli.main(["x", "bump-kind", str(f), "v1.9.2"]) + assert capsys.readouterr().out == "minor" + + def test_payload(self, tmp_path, capsys): + f = tmp_path / "CHANGELOG.md" + f.write_text(self.SAMPLE) + cli.main(["x", "payload", str(f), "1.10.0"]) + out = json.loads(capsys.readouterr().out) + assert out["version"] == "1.10.0" + assert out["date_label"] == "June 11, 2026" + # CLI emits raw markdown; Slack conversion happens later, in the Lambda. + assert "**Self-announcing**" in out["notes"] diff --git a/tests/shared/test_blocks.py b/tests/shared/test_blocks.py index 8fdc4dc..2308662 100644 --- a/tests/shared/test_blocks.py +++ b/tests/shared/test_blocks.py @@ -5,11 +5,13 @@ from freezegun import freeze_time from shared.blocks import ( build_help_blocks, build_pay_summary_blocks, + build_release_announcement_blocks, build_roster_blocks, build_shift_change_message, build_swap_request_blocks, build_swap_resolved_blocks, build_week_schedule, + markdown_to_mrkdwn, ) @@ -157,3 +159,52 @@ class TestBuildRosterBlocks: assert text.index("Alice") < text.index("Bob") assert "<@U_ALICE>" in text assert "_not linked_" in text + + +class TestReleaseAnnouncement: + def test_markdown_bold_becomes_slack_bold(self): + assert markdown_to_mrkdwn("**Big news.** text") == "*Big news.* text" + + def test_markdown_italic_becomes_underscore(self): + # Single asterisks are italic in Markdown; Slack uses underscores. + assert markdown_to_mrkdwn("a *note* here") == "a _note_ here" + + def test_bold_and_italic_together(self): + out = markdown_to_mrkdwn("**Bold.** Then *(an aside)*") + assert out == "*Bold.* Then _(an aside)_" + + def test_links_and_bullets(self): + out = markdown_to_mrkdwn("- see [docs](http://x)\n- next") + assert "• see <http://x|docs>" in out + assert "• next" in out + + def test_adversarial_input_runs_in_linear_time(self): + # py/polynomial-redos regression: possessive quantifiers must keep these + # patterns from catastrophic backtracking. Pathological inputs that would + # hang a backtracking engine complete effectively instantly here. + import time + + for evil in ("*" + "*a" * 4000, "[" + "[\\(" * 4000, "[" + "](" * 4000): + start = time.perf_counter() + markdown_to_mrkdwn(evil) + assert time.perf_counter() - start < 1.0 + + def test_blocks_have_header_and_notes(self): + blocks = build_release_announcement_blocks( + "1.10.0", "**Release notes** now self-announce.", "June 11, 2026" + ) + assert blocks[0]["type"] == "header" + assert blocks[0]["text"]["text"] == "What's New — v1.10.0" + assert any(b.get("type") == "context" for b in blocks) + section = blocks[-1] + assert section["type"] == "section" + assert "*Release notes*" in section["text"]["text"] + + def test_date_label_optional(self): + blocks = build_release_announcement_blocks("2.0.0", "Notes.") + assert not any(b.get("type") == "context" for b in blocks) + + def test_long_notes_truncated_under_section_limit(self): + blocks = build_release_announcement_blocks("1.10.0", "x " * 3000) + assert len(blocks[-1]["text"]["text"]) <= 3000 + assert "full changelog" in blocks[-1]["text"]["text"] diff --git a/tests/shared/test_changelog.py b/tests/shared/test_changelog.py new file mode 100644 index 0000000..135a5ea --- /dev/null +++ b/tests/shared/test_changelog.py @@ -0,0 +1,119 @@ +"""Tests for the CHANGELOG parser — the single source of truth for versioning.""" + +import pytest + +from shared.changelog import ( + bump_kind, + entry_for, + is_valid_bump, + latest_entry, + parse_changelog, + top_version, + version_entries, +) + +# A faithful slice of the real file: preamble prose, a plain version entry, a +# legacy combined entry, and date-only historical headers with no version. +SAMPLE = """# Changelog + +What's changed in the **After-Hours Shift Manager**. Newest first. + +Versions are `MAJOR.MINOR.PATCH`. + +--- + +## v1.10.0 — June 11, 2026 + +**Release notes now announce themselves.** Big new feature line. + +## v1.9.2 — June 1, 2026 + +**Setup-guide fix.** A patch. + +## v1.9.0 / v1.9.1 — June 1, 2026 + +**Last-minute drops blocked.** Combined entry. *(v1.9.1 was a library update.)* + +--- + +## May 2026 — Phone-system automation + +- Live phone routing follows the schedule. + +## April 3, 2026 — Launch 🎉 + +The first version. +""" + + +def test_preamble_is_not_an_entry(): + # The "# Changelog" h1 and prose before the first "##" must not parse as entries. + entries = parse_changelog(SAMPLE) + assert all("Changelog" not in e.title for e in entries) + + +def test_top_version_skips_preamble(): + assert top_version(SAMPLE) == "1.10.0" + + +def test_latest_entry_fields(): + entry = latest_entry(SAMPLE) + assert entry.version == "1.10.0" + assert entry.date_label == "June 11, 2026" + assert "announce themselves" in entry.body + + +def test_combined_header_reports_higher_version(): + entry = entry_for(SAMPLE, "1.9.0") + assert entry.versions == ("1.9.0", "1.9.1") + assert entry.version == "1.9.1" # the higher of the two + + +def test_combined_header_is_findable_by_either_version(): + assert entry_for(SAMPLE, "1.9.1") == entry_for(SAMPLE, "v1.9.0") + + +def test_body_excludes_thematic_break_rules(): + # The "---" rule separating eras must not bleed into the combined entry's notes. + assert "---" not in entry_for(SAMPLE, "1.9.0").body + + +def test_date_only_headers_carry_no_version(): + titles = {e.title for e in parse_changelog(SAMPLE) if not e.versions} + assert "May 2026 — Phone-system automation" in titles + assert "April 3, 2026 — Launch 🎉" in titles + + +def test_version_entries_excludes_date_only(): + versions = [e.version for e in version_entries(SAMPLE)] + assert versions == ["1.10.0", "1.9.2", "1.9.1"] + + +def test_entry_for_accepts_v_prefix(): + assert entry_for(SAMPLE, "v1.10.0").version == "1.10.0" + + +def test_entry_for_unknown_version_is_none(): + assert entry_for(SAMPLE, "2.0.0") is None + + +def test_empty_changelog_has_no_top_version(): + assert top_version("# Changelog\n\nNothing yet.\n") is None + assert latest_entry("# Changelog\n") is None + + +@pytest.mark.parametrize( + "new,prev,expected", + [ + ("1.10.0", "1.9.2", "minor"), # minor resets patch to 0 + ("2.0.0", "1.9.2", "major"), + ("1.9.3", "1.9.2", "patch"), + ("1.11.0", "1.9.2", None), # skips a minor + ("1.9.2", "1.9.2", None), # no change + ("1.9.1", "1.9.2", None), # downgrade + ("3.0.0", "1.9.2", None), # skips a major + ], +) +def test_bump_kind(new, prev, expected): + assert bump_kind(new, prev) == expected + assert is_valid_bump(new, prev) is (expected is not None) diff --git a/tests/slack_bot/test_app_home.py b/tests/slack_bot/test_app_home.py new file mode 100644 index 0000000..89bf467 --- /dev/null +++ b/tests/slack_bot/test_app_home.py @@ -0,0 +1,46 @@ +"""Tests for the App Home tab (about page + What's New).""" + +from shared.blocks import build_home_view + + +def _headers(view): + return [b["text"]["text"] for b in view["blocks"] if b["type"] == "header"] + + +class TestBuildHomeView: + def test_includes_about_and_commands(self): + view = build_home_view() + assert view["type"] == "home" + assert "After-Hours Shift Manager" in _headers(view) + assert "/oncall" in str(view["blocks"]) + + def test_omits_whats_new_without_version(self): + view = build_home_view() + assert all("What's New" not in h for h in _headers(view)) + + def test_includes_whats_new_with_version(self): + view = build_home_view( + "1.10.0", "**Self-announcing** releases.", "June 11, 2026" + ) + assert "What's New in v1.10.0" in _headers(view) + # Notes are converted from Markdown to Slack mrkdwn. + assert "*Self-announcing*" in str(view["blocks"]) + assert any(b.get("type") == "context" for b in view["blocks"]) + + +def test_publish_home_publishes_latest_entry(slackbot_app, client): + text = "## v1.10.0 — June 11, 2026\n\n**New stuff.**\n\n## v1.9.2 — June 1, 2026\n\nOld.\n" + slackbot_app.publish_home(client, "U_ALICE", text) + + client.views_publish.assert_called_once() + kwargs = client.views_publish.call_args.kwargs + assert kwargs["user_id"] == "U_ALICE" + assert kwargs["view"]["type"] == "home" + assert "What's New in v1.10.0" in str(kwargs["view"]) + assert "v1.9.2" not in str(kwargs["view"]) # only the newest entry + + +def test_publish_home_degrades_without_changelog(slackbot_app, client): + slackbot_app.publish_home(client, "U_BOB", "") + view = client.views_publish.call_args.kwargs["view"] + assert all("What's New" not in str(b) for b in view["blocks"])