Doof (Heinz Doofenshmirtz) is MIT Open Learning's Slack release bot. It listens for
@doof <command> in Slack, maps each channel to a project via repos_info.json, and
drives the ODL release lifecycle: release notes -> release-candidate branch with a
version bump -> "Release X.Y.Z" PR with per-author checkboxes -> wait for RC deploy ->
wait for checkbox sign-off -> merge to release, verify prod. Web applications are
tagged when the release candidate is cut; libraries are tagged when the release is
finished, then published to PyPI (twine) or npm.
README.md is accurate and current - read it for the human-facing side this file
does not cover: the full Doof command list, the release lifecycle in prose, and the
Heroku deployment. This file covers what you need to change the code.
Python 3.13, managed with uv. npm install is required, not optional - the
git-release-notes binary is invoked from node_modules/.bin/.
npm install # JS deps (git-release-notes)
uv sync # Python deps, including the dev group
uv run ruff check # lint
uv run pytest . # full suite; coverage is auto-applied via pytest.ini
uv run ruff format # format (not gated by CI)
uv run pytest bot_test.py::test_release -x # single test
uv run python3 bot_local.py product-mitx-online release 4.5.6 # drive Doof from a shellNever use pip, poetry, or a hand-rolled venv - always uv. Run everything through
uv run so it uses the project environment.
There is no typechecker and no pre-commit config. pyproject.toml has no [tool.*]
sections, so ruff runs on defaults.
CI (.github/workflows/ci.yml, on: push) runs exactly the first four commands -
npm install, uv sync, uv run ruff check, uv run pytest . - and nothing else.
A second workflow runs zizmor over .github/workflows/** - any new workflow step
must be SHA-pinned with least-privilege permissions: or it fails.
Two local gotchas:
nodemust be on your PATH, not just installed undernode_modules/. If your environment provides only python and uv (a nix devshell, a slim container), ~10 tests inrelease_test.py/finish_release_test.py/version_test.pyfail withFileNotFoundError: 'node'. CI'subuntu-24.04runner has node preinstalled, so this is a local-only gap - check for it before blaming your change.lib.pyis notruff format-clean. CI only gatesruff check, so a blinduv run ruff formatadds an unrelatedlib.pydiff. Format only the files you touched.
Flat modules at the repo root - no package directory, no src/. Each module has a
sibling <module>_test.py.
| File | Responsibility |
|---|---|
bot.py |
Bot: Slack I/O, command table, release lifecycle state machine, get_envs(), main() |
bot_local.py |
ConsoleBot - run one command from a shell instead of Slack |
web.py |
Tornado app: button/event handlers, HMAC is_authenticated() |
release.py |
Cut the release: notes, version bump, generate_release_pr |
finish_release.py |
Merge release-candidate -> release, tag |
publish.py |
upload_to_pypi (twine in a virtualenv), upload_to_npm |
version.py |
Read/write versions per versioning strategy |
github.py |
GitHub REST + GraphQL (run_query), PRs, labels |
slack.py |
Cursor pagination, channel info, Doof's user ID |
lib.py |
Shared helpers: parse_checkmarks, get_release_pr, init_working_dir, load_repos_info |
status.py |
Per-repo release status/emoji for status |
wait_for_deploy.py |
Poll a hash.txt URL until the deployed hash matches |
async_subprocess.py |
asyncio check_call/check_output/call - cwd is required |
client_wrapper.py |
requests.Session with retries, wrapped in async methods |
constants.py |
Project/versioning/deploy types, GitHub label names, tool paths |
exception.py |
InputException, ReleaseException, and friends |
repo_info.py |
The RepoInfo namedtuple |
repos_info.json |
Config: repo -> channel, URLs, project type, versioning strategy |
- Async everywhere. Subprocesses go through
async_subprocess, which requires an explicitcwd=becauseos.chdiris not per-coroutine safe. Never callsubprocessoros.chdirdirectly in a coroutine. - Never touch the working tree. All repo work happens in a temp clone via the
init_working_dirasync context manager inlib.py. - Keyword-only args are the norm:
async def finish_release(*, github_access_token, repo_info, version). Google-style docstrings (Args:/Returns:) on essentially every function. namedtuple, not dataclasses, for structs (RepoInfo,Command,Parser,ReleasePR).- Release state lives in GitHub PR labels, not a database:
deploying to rc,waiting for checkboxes,all checkboxes checked,deploying to prod,deployed to prod(seeRELEASE_LABELS). Progress is also read from the release PR body's checkboxes vialib.parse_checkmarks. - Bare
except: # noqa: E722inbot.py's top-level handlers is deliberate - it keeps the bot alive. Errors surface to Slack as Doofenshmirtz-flavored quips;InputException/ReleaseExceptionare the expected errors reported verbatim. - Target repos' default branch is resolved at runtime by
lib.get_default_branch(mainand legacymasterboth supported). Do not hardcode it.
- Test files are
<module>_test.py, colocated at the repo root - nottest_*.py. pytest-asynciois in strict mode, so every async test module needs a module-levelpytestmark = pytest.mark.asyncioor its tests silently do not run.- Use
mocker.async_patch(target, **kwargs), the custompytest-mockextension defined inconftest.py, instead ofAsyncMock. It returns the underlyingMockso you can assert calls:mocker.async_patch("bot.get_release_pr", return_value=None). - An autouse
log_exceptionfixture makesbot.log.exception/bot.log.errorraise, so a swallowed exception fails the test loudly. Expect that if you add broad excepts. - Repo fixtures in
conftest.py:test_repo,library_test_repo,npm_library_test_repo. They build real git repos bygit fast-import-ingtest-repo.gz(seetest_util.make_test_repo). - No
responses/vcr/betamax. Mock GitHub withmocker.async_patch("github.run_query", return_value=<payload>)using the canned JSON payloads; fake Slack with theDoofSpoof(Bot)subclass inbot_test.py, which records messages and exposesdoof.said(...)for assertions. - Use the
sleep_sync_mockfixture (bot_test.py) so lifecycle tests do not actually wait. @pytest.mark.parametrizeis used heavily - follow suit over copy-pasted cases.
bot.get_envs() hard-fails at startup unless all of these are set:
SLACK_ACCESS_TOKEN, BOT_ACCESS_TOKEN, GITHUB_ACCESS_TOKEN, NPM_TOKEN,
SLACK_SECRET, PORT, PYPI_USERNAME, PYPI_PASSWORD,
PYPITEST_USERNAME, PYPITEST_PASSWORD. Optional: SENTRY_SDK (the Sentry DSN,
despite the name).
BOT_ACCESS_TOKEN and the PYPITEST_* pair are required but currently unused - a
known wart. bot_local.py enforces the same list, so fill in fakes for whatever your
command does not need.
Runtime deps are exact-pinned (==) in pyproject.toml; only ruff floats.
Keep it that way - Renovate manages the bumps.