mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 05:33:12 +00:00
Add changelog-driven releases and App Home tab (#112)
* 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.
This commit is contained in:
parent
1c9ddaeadf
commit
53c85f7eed
22 changed files with 1281 additions and 51 deletions
38
.github/workflows/changelog-guard.yml
vendored
Normal file
38
.github/workflows/changelog-guard.yml
vendored
Normal file
|
|
@ -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
|
||||
45
.github/workflows/deploy-notify.yml
vendored
45
.github/workflows/deploy-notify.yml
vendored
|
|
@ -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"
|
||||
98
.github/workflows/deploy.yaml
vendored
98
.github/workflows/deploy.yaml
vendored
|
|
@ -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 }}"
|
||||
|
|
|
|||
13
CHANGELOG.md
13
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
|
||||
|
|
|
|||
54
README.md
54
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 /
|
||||
|
|
|
|||
52
scripts/changelog_cli.py
Normal file
52
scripts/changelog_cli.py
Normal file
|
|
@ -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 <file> # newest entry's version
|
||||
changelog_cli.py bump-kind <file> <prev> # major|minor|patch|none vs <prev>
|
||||
changelog_cli.py payload <file> <version> # 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))
|
||||
72
scripts/check_changelog.py
Normal file
72
scripts/check_changelog.py
Normal file
|
|
@ -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())
|
||||
24
scripts/sync_changelog.py
Normal file
24
scripts/sync_changelog.py
Normal file
|
|
@ -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()
|
||||
49
slack-app-manifest.yaml
Normal file
49
slack-app-manifest.yaml
Normal file
|
|
@ -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 <API_URL> 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: <API_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: <API_URL>
|
||||
bot_events:
|
||||
- app_home_opened
|
||||
interactivity:
|
||||
is_enabled: true
|
||||
request_url: <API_URL>
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: false
|
||||
45
src/release-notifier/app.py
Normal file
45
src/release-notifier/app.py
Normal file
|
|
@ -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": "<markdown>", "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"]}
|
||||
2
src/release-notifier/requirements.txt
Normal file
2
src/release-notifier/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
slack_sdk>=3.42.0,<4.0
|
||||
boto3>=1.43.27
|
||||
|
|
@ -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_``, ``<url|text>``, ``•`` 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]:
|
||||
|
|
|
|||
136
src/shared/shared/changelog.py
Normal file
136
src/shared/shared/changelog.py
Normal file
|
|
@ -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<title>.+?)\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
|
||||
101
src/slack-bot/CHANGELOG.md
Normal file
101
src/slack-bot/CHANGELOG.md
Normal file
|
|
@ -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`.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
tests/release_notifier/conftest.py
Normal file
22
tests/release_notifier/conftest.py
Normal file
|
|
@ -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")
|
||||
67
tests/release_notifier/test_handler.py
Normal file
67
tests/release_notifier/test_handler.py
Normal file
|
|
@ -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()
|
||||
70
tests/scripts/test_release_tooling.py
Normal file
70
tests/scripts/test_release_tooling.py
Normal file
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
119
tests/shared/test_changelog.py
Normal file
119
tests/shared/test_changelog.py
Normal file
|
|
@ -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)
|
||||
46
tests/slack_bot/test_app_home.py
Normal file
46
tests/slack_bot/test_app_home.py
Normal file
|
|
@ -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"])
|
||||
Loading…
Add table
Reference in a new issue