mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-05 13:02:05 +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:
|
secrets:
|
||||||
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
||||||
parameter-overrides: ${{ secrets.SAM_PARAMETER_OVERRIDES }}
|
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**
|
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.
|
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
|
## v1.9.2 — June 1, 2026
|
||||||
|
|
||||||
**Setup-guide fix.** Corrected the install instructions so secrets (the Slack and
|
**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 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
|
## Slack Commands
|
||||||
|
|
||||||
| Command | Description |
|
| 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-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-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-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
|
### Project Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
slack-bot/ Slack Bolt Lambda (handler + app)
|
slack-bot/ Slack Bolt Lambda (handler + app); ships CHANGELOG.md for App Home
|
||||||
weekly-post/ Monday schedule + pay post
|
weekly-post/ Monday schedule + pay post
|
||||||
roster-sync/ Daily 3CX roster sync
|
roster-sync/ Daily 3CX roster sync
|
||||||
ring-scheduler/ 3CX queue routing updates
|
ring-scheduler/ 3CX queue routing updates
|
||||||
shared/ Lambda Layer (schedule, blocks, 3CX client, secrets)
|
release-notifier/ Posts release announcements to Slack
|
||||||
tests/ pytest suite (mirrors src/, one dir per Lambda + shared)
|
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
|
### DynamoDB Schema
|
||||||
|
|
@ -114,6 +119,43 @@ sam build
|
||||||
sam deploy
|
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
|
## Testing
|
||||||
|
|
||||||
Unit tests use `pytest` with all external boundaries mocked — DynamoDB / SES /
|
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."""
|
"""Slack Block Kit message builders for shift schedule display."""
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
@ -7,6 +8,62 @@ from shared.schedule import WEEKEND_DAYS
|
||||||
|
|
||||||
EASTERN = ZoneInfo("America/New_York")
|
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 = {
|
SHIFT_LABELS = {
|
||||||
"day": "Day (8am–5pm)",
|
"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}}]
|
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(
|
def build_pay_summary_blocks(
|
||||||
week_label: str, pay_breakdown: list[dict], total_by_person: dict[str, dict]
|
week_label: str, pay_breakdown: list[dict], total_by_person: dict[str, dict]
|
||||||
) -> list[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.
|
is a thin wiring layer that registers the Bolt routes and delegates to them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -16,6 +17,7 @@ from slack_bolt import App
|
||||||
|
|
||||||
from shared.blocks import (
|
from shared.blocks import (
|
||||||
build_help_blocks,
|
build_help_blocks,
|
||||||
|
build_home_view,
|
||||||
build_pay_summary_blocks,
|
build_pay_summary_blocks,
|
||||||
build_roster_blocks,
|
build_roster_blocks,
|
||||||
build_shift_change_message,
|
build_shift_change_message,
|
||||||
|
|
@ -23,6 +25,7 @@ from shared.blocks import (
|
||||||
build_swap_resolved_blocks,
|
build_swap_resolved_blocks,
|
||||||
build_week_schedule,
|
build_week_schedule,
|
||||||
)
|
)
|
||||||
|
from shared.changelog import latest_entry
|
||||||
from shared.ring_scheduler import update_queue_routing
|
from shared.ring_scheduler import update_queue_routing
|
||||||
from shared.schedule import (
|
from shared.schedule import (
|
||||||
FALLBACK_EXTENSION,
|
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`.")
|
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 ─────────────────────────────────────────────────────────
|
# ── App factory ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -960,4 +994,11 @@ def create_app(
|
||||||
ack()
|
ack()
|
||||||
handle_swap_decline(body, respond, client, schedule, schedule_channel)
|
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
|
return app
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,69 @@ Resources:
|
||||||
Description: "Update 3CX queue at 5pm EDT weekends"
|
Description: "Update 3CX queue at 5pm EDT weekends"
|
||||||
Enabled: true
|
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) ---
|
# --- CloudWatch Log Groups (explicit 60-day retention) ---
|
||||||
SlackBotLogGroup:
|
SlackBotLogGroup:
|
||||||
Type: AWS::Logs::LogGroup
|
Type: AWS::Logs::LogGroup
|
||||||
|
|
@ -274,6 +337,12 @@ Resources:
|
||||||
LogGroupName: !Sub "/aws/lambda/${RingSchedulerFunction}"
|
LogGroupName: !Sub "/aws/lambda/${RingSchedulerFunction}"
|
||||||
RetentionInDays: 60
|
RetentionInDays: 60
|
||||||
|
|
||||||
|
ReleaseNotifierLogGroup:
|
||||||
|
Type: AWS::Logs::LogGroup
|
||||||
|
Properties:
|
||||||
|
LogGroupName: !Sub "/aws/lambda/${ReleaseNotifierFunction}"
|
||||||
|
RetentionInDays: 60
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
SlackBotApiUrl:
|
SlackBotApiUrl:
|
||||||
Description: URL for Slack app Request URL configuration
|
Description: URL for Slack app Request URL configuration
|
||||||
|
|
@ -288,3 +357,8 @@ Outputs:
|
||||||
Value: !GetAtt RosterSyncFunction.Arn
|
Value: !GetAtt RosterSyncFunction.Arn
|
||||||
RingSchedulerFunctionArn:
|
RingSchedulerFunctionArn:
|
||||||
Value: !GetAtt RingSchedulerFunction.Arn
|
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 (
|
from shared.blocks import (
|
||||||
build_help_blocks,
|
build_help_blocks,
|
||||||
build_pay_summary_blocks,
|
build_pay_summary_blocks,
|
||||||
|
build_release_announcement_blocks,
|
||||||
build_roster_blocks,
|
build_roster_blocks,
|
||||||
build_shift_change_message,
|
build_shift_change_message,
|
||||||
build_swap_request_blocks,
|
build_swap_request_blocks,
|
||||||
build_swap_resolved_blocks,
|
build_swap_resolved_blocks,
|
||||||
build_week_schedule,
|
build_week_schedule,
|
||||||
|
markdown_to_mrkdwn,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -157,3 +159,52 @@ class TestBuildRosterBlocks:
|
||||||
assert text.index("Alice") < text.index("Bob")
|
assert text.index("Alice") < text.index("Bob")
|
||||||
assert "<@U_ALICE>" in text
|
assert "<@U_ALICE>" in text
|
||||||
assert "_not linked_" 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