Metadata-Version: 2.5
Name: bmsdna-devtools
Version: 0.35.0
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
Requires-Python: >=3.14
Requires-Dist: pgdevkit[db]>=0.7.1
Requires-Dist: requests>=2.32.0
Requires-Dist: rich>=13.0.0
Requires-Dist: tomli-w>=1.0.0
Requires-Dist: typer>=0.26.7
Provides-Extra: lint
Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
Description-Content-Type: text/markdown

# bmsdna-devtools

Shared developer tooling for BMS projects: PR build/check status and a
one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, git worktrees (creation and
merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
backend routes nothing calls.
`bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
remote is Azure DevOps or GitHub and use `az`/`gh` accordingly.
Consolidates near-duplicate scripts that used to be copy-pasted across
OneSales, ccmt2, and MDMApp into one versioned package with a `bdt` CLI.

Requires `git` always, plus `az` (Azure DevOps commands, and all `bdt logs`
commands) and/or `gh` (GitHub commands) on PATH as needed — each is checked
lazily, only when a command actually needs it, with a clear error and an
install link if missing rather than a raw traceback. Works on Windows: CLI
shims (e.g. `az.cmd`) are resolved via `shutil.which` (which honors
`PATHEXT`) rather than shelling out, output is decoded as UTF-8 rather than
relying on the console's default codepage, and file arguments accept either
slash style.

## Install

```bash
uv tool install bmsdna-devtools
```

Or as a project dependency: `uv add bmsdna-devtools`.

## `bdt pr status`

Find the PR opened from the current branch and report build/check status
(failed steps print their logs inline). Works against Azure DevOps or
GitHub — whichever `origin` points at.

```bash
bdt pr status [--target-branch BRANCH] [--wait]
```

The `--target` / `--target-branch` option of `pr create/retry/publish/update/comment/watch-deploy` and `commit`
defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override. `pr status`, `pr info` and
`issue take` only look a PR up, so by default they don't filter on the target branch at all.

If the PR can't be merged, that's reported immediately instead of polling
for builds/checks that will never run — e.g. on Azure DevOps:
`PR #42 ('feat: widgets') has merge conflicts with the target branch (mergeStatus=conflicts)`;
on GitHub: `PR #42 ('feat: widgets') has merge conflicts with 'main' (mergeable=CONFLICTING)`.
Exit code 1 either way.

With `--wait`, a build/check that's paused on a manual approval (an Azure
Pipelines stage's Checkpoint.Approval, or a GitHub Actions deployment
protection rule) ends the wait instead of polling forever — it prints which
stage/check needs a reviewer plus a link to act on it, and exits **3**
(`bdt pr watch-deploy --wait`, below, behaves the same way). If another
build/check has already failed, that's reported instead (exit 1) even when
one is also waiting on approval.

**Azure DevOps**: org/project/repo are auto-detected from
`git remote get-url origin` (handles SSH, `dev.azure.com` HTTPS, and
`*.visualstudio.com` HTTPS forms). Auth is an explicit PAT (`--pat` or
`AZURE_DEVOPS_EXT_PAT`/`AZURE_DEVOPS_PAT` env var), falling back to a short-lived token from the
caller's own `az login` — never embed a PAT literal in a script or CI file.
`--target-branch` optionally restricts the lookup to PRs into that branch (default: any).

**GitHub**: uses `gh`'s own auth (`gh auth login`) and always resolves the
PR opened from the current branch — `gh pr view` has no target-branch
filter, so `--target-branch` is ignored here; the PR's actual base branch
is shown in the output. Check status is computed from
`gh pr view --json statusCheckRollup` rather than `gh pr checks --json`,
since the latter flag isn't available in all `gh` releases.

Pass `--pr-id <number>` to check a specific PR directly instead of resolving
it from the current branch — useful when that PR's branch isn't checked out
locally at all. `bdt pr retry`, `pr publish`, `pr update`, and `pr comment`
all accept the same option, for the same reason. (`pr watch-deploy` doesn't:
it watches a branch-triggered build/workflow run, not any particular PR.)

## `bdt pr info`

```bash
bdt pr info [--json] [--pr-id N] [--target-branch BRANCH]
```

One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
build state (`passing`, `failing`, `pending`, `waiting` for a manual approval, `none`) and the
issue(s)/work item(s) it closes, with links. Exits 1 when the branch has no PR. `--json` is what
a status integration reads (e.g. the Claude Code mod planned in bmsuisse/skills#61). Build state is judged the way `bdt pr status` does: on GitHub from
the check rollup (only a failed check fails it; skipped/cancelled ones don't count), on Azure DevOps
from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
`waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked.

Azure DevOps limits: the PR is found by the current branch (into `--target-branch` if given, else any target),
a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed
work items are only those whose URL is in the PR description -- not ones linked via
`bdt pr create --issue`.

## `bdt pr retry`

Retry only the **failed** job(s)/stage(s) of the most recent build/run for the
PR opened from the current branch — not a whole new build/run. Whenever `bdt
pr status` reports a failure, it prints a hint to run this.

```bash
bdt pr retry [--target-branch dev]
```

No build/run ID needed — like `bdt pr status`, it resolves the PR (and its
latest build/run per pipeline/workflow) from the current branch, or from
`--pr-id` directly if given.

**Azure DevOps**: uses the `retry=true` query parameter on the "Update
Build" REST API
(`PATCH .../_apis/build/builds/{buildId}?retry=true&api-version=7.1`), which
reschedules whichever stage(s)/job(s) failed on the previous attempt (plus
anything depending on them) in place — distinct from queuing a brand new
build. One retry call per pipeline that has a failed build for the PR.
`--target-branch` selects which PR to look at, same as `bdt pr status`.

**GitHub**: uses `gh run rerun <run-id> --failed`, which reruns only the
failed job(s) (and their dependents) of a workflow run — the run ID(s) are
found automatically from the PR's failing checks. Checks that aren't backed
by a GitHub Actions run (e.g. a legacy commit status from an external CI)
can't be retried this way and are skipped; if none of the failing checks are
retryable, the command exits with an error.

## `bdt pr watch-deploy`

Many pipelines have a second build/stage that only runs on the *target*
branch once a PR merges into it, and that's the one that actually deploys.
`bdt pr status` only watches builds/checks tied to the PR itself (its
merge/source ref or GitHub's `statusCheckRollup`), so it never sees that
second build. `bdt pr watch-deploy` does: it finds the most recent build/
workflow run triggered *directly* on `--target-branch` and reports its
status the same way `pr status` does (failed steps print their logs inline).

```bash
bdt pr watch-deploy [--target-branch dev] [--wait]
```

After `bdt pr status` reports the PR's build/checks succeeded, if a build/
run has already been triggered on the target branch (e.g. the merge already
kicked one off), it prints a hint suggesting this command. That check is
best-effort — a failure reading it (auth, permissions, network) is silently
skipped rather than blocking or crashing `pr status`.

**Azure DevOps**: looks up builds via the same `_apis/build/builds` endpoint
`pr status` uses, just queried by `branchName=refs/heads/<target-branch>`
instead of the PR's merge/source ref.

**GitHub**: uses `gh run list --branch <target-branch> --event push`, which
selects workflow runs triggered by a push to that branch — as opposed to a
`pull_request`-triggered run for some still-open PR targeting the same
branch. Failed steps are printed via `gh run view <id> --log-failed`.

## `bdt pr create`

```bash
bdt pr create --target dev   # or --target main / test
```

Creates a PR from the current branch into `--target`. On Azure DevOps,
a thin wrapper around `az repos pr create` (org/project/repo inferred by
`az` itself from the git remote). On GitHub, `gh pr create --fill` (autofills
title/body from commit info so it never blocks on an interactive prompt).
Extra arguments pass through either way, e.g.
`bdt pr create --target dev -- --title "..."`.

PRs are created as **drafts by default**; a successful create always prints
the PR's link plus `bdt pr publish` (which abstracts over the host) to mark
it ready for review. Pass `--no-draft` to open it ready for review
immediately instead.

`--label` (repeatable) applies labels to the PR on either host: on GitHub
these map to `gh pr create --label`, so the label must already exist on the
repo (`gh label create`); on Azure DevOps they map to `az repos pr create
--labels`, which are freeform and get created on the fly. A successful
create prints the PR's web/GUI link (not just the REST API URL Azure DevOps'
`az` output otherwise gives you).

`[tool.bdt.pr.required_labels]` in pyproject.toml can require at least one
label from each named group before the PR is created — checked locally
(no `gh`/`az` call happens if a group isn't satisfied):

```toml
[tool.bdt.pr.required_labels]
type = ["bug", "feature", "chore"]
risk = ["breaking", "non-breaking"]
```

With the above, `bdt pr create --label feature --label breaking` passes,
but `bdt pr create --label feature` fails with a message naming the unmet
group (`risk`) and its allowed choices.

`[tool.bdt.pr.scope_labels]` in pyproject.toml can auto-apply a label based on
the "scope" of the HEAD commit's conventional-commit subject (the
`type(scope): description` format `bdt commit` itself expects, e.g.
`feat(customers): ...`):

```toml
[tool.bdt.pr.scope_labels]
customers = "e2e-customers"
billing = "e2e-billing"
```

With the above, creating a PR whose HEAD commit is `feat(customers): add
widget` automatically adds the `e2e-customers` label (merged with, not
replacing, any explicit `--label`). No match, no `[tool.bdt.pr.scope_labels]`
table, or a commit subject without a `(scope)` all mean no label gets added —
opt-in, so repos that don't configure it see no change in behavior.

If `--target` has a build policy configured (an Azure DevOps Build policy,
or a GitHub branch protection rule requiring status checks), a successful
create prints a reminder to run `bdt pr status` afterward to check whether
the CI build passes. This is a best-effort check — failures reading policy
config (auth, permissions) fail open and simply skip the reminder.

## `bdt issue create` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`

```bash
bdt issue create --title "Nightly job fails" --description "..." --type Bug --screenshot before.png
bdt issue update 1234 --state Resolved --tag fixed
bdt issue delete 1234 --yes

bdt issue comment add 1234 --message "Repro'd, see attached" --screenshot repro.png
bdt issue comment update 1234 5678 --message "Actually, see the second screenshot"
bdt issue comment delete 1234 5678 --yes
```

Creates/updates/deletes an issue (GitHub) or work item (Azure DevOps), and
adds/edits/deletes comments on one, auto-detected from `origin` like `bdt pr
*`. `bdt issue comment add` prints the new comment's ID so you can pass it to
`update`/`delete` later.

Destructive commands (`issue delete`, `issue comment delete`) require an
explicit `--yes` — there's no interactive confirmation prompt, since `bdt` is
also invoked by AI-agent callers that can't answer one.

`--tag` and `--label` are aliases of each other (repeatable on `create`,
`search`, and `update`) — pass whichever reads naturally; the values are
merged and routed to tags on Azure DevOps or labels on GitHub, whichever
backend is actually active, instead of being silently ignored by the other
one. Same for `update`'s `--remove-tag`/`--remove-label`.

**Azure DevOps**: `--type` selects the work item type on `create` (`Bug`,
`Task`, `User Story`, ... — whatever the project's process defines; default
`Bug`). `--tag`/`--label` set/replace the full tag list (repeatable; omit
both on `update` to leave tags unchanged). `update`'s `--remove-tag`/
`--remove-label` instead removes just the named tag(s) and leaves the rest —
unlike `--tag`/`--label`, which replace the whole set — by reading the work
item's current tags first (there's no Azure DevOps "remove one tag" patch
op). `--state` (`update` only) sets
`System.State`, e.g. `Active`, `Resolved`, `Closed`. `--screenshot` uploads
each image as a work item attachment (visible in the Attachments tab) and
posts a comment embedding them inline with Markdown — the Description field
defaults to HTML via the REST API, where a raw `![]()` would just show as
literal text, but the work item Discussion/Comments control has always
rendered Markdown. `issue delete` soft-deletes to the project's Recycle Bin
(restorable, not permanent).

`--board <team>` sets the work item's Area Path to that Azure Boards team's
default, so it shows up on that team's board — a CLI flag beats
`[tool.bdt.ado].board` in `pyproject.toml`, which beats filing under the
project's root area:

```toml
[tool.bdt.ado]
board = "My Team"
```

On `update`, `--board` only moves the item when you pass it explicitly — it
never falls back to `pyproject.toml`, so an unrelated field update (e.g.
just `--title`) can't silently relocate the item to a different board.

**GitHub**: a thin wrapper around `gh issue create` / `edit` / `delete` /
`comment`. `--label`/`--tag` add a label on `create`, or add/remove one on
`update` (paired with `--remove-label`/`--remove-tag`); labels must already
exist in the repo. `--screenshot` pushes images to a `pr-assets` branch (same trick `bdt
pr create --screenshot` uses, since GitHub has no API for uploading an image
into an issue) and appends them to the issue body / comment as Markdown.
`issue delete` is **permanent** — GitHub has no recycle bin for issues.
Comment update/delete go through `gh api` directly (`gh issue` has no
subcommand for editing/deleting an arbitrary comment by ID). Extra arguments
to `bdt issue create` pass through to `gh issue create`, e.g.
`bdt issue create --title "..." -- --assignee @me`.

`--board <name>` on `create` adds the issue to that GitHub Projects (v2)
board by title (needs a token with the `project` scope — `gh auth refresh -s
project`). On `search`, `--board` (title or number) scopes results to issues
currently on that board; since `gh issue list` has no board filter of its
own, this fetches a wider raw pool and filters it down client-side, so a
board with few matches may return fewer results than `--limit` asks for. A
CLI flag beats `[tool.bdt.github].board` in `pyproject.toml`:

```toml
[tool.bdt.github]
board = "Roadmap"
```

## `bdt issue do`

```bash
bdt issue do 60 [--agent claude] [--dry-run] [extra agent args...]
```

Hands an issue (GitHub) or work item (Azure DevOps) to a coding agent. Fetches the title and
description and runs `claude -p "<prompt>" --name "60: <title>"`, so the session is easy to find
with `claude --resume`. `--agent` picks another executable (it then gets just the prompt as its
last argument); extra args are passed through; `--dry-run` prints the command.

It takes the issue first (like `bdt issue take`): before the agent starts it comments `Taken by <you>`
on the issue, naming the session it is about to start -- for `claude` a `--session-id` is generated and
passed, so the comment says `claude --resume <id>`; the prompt tells the agent not to take it again. For
another `--agent` the comment just says `(via <agent>)`. `--dry-run` posts nothing. Like `issue take`, it doesn't comment again when the newest comment already is your claim; if it is someone else's, it stops (exit 1) unless `--force`.

## `bdt issue take`

```bash
bdt issue take [NUMBER] [--target-branch BRANCH] [--force]
```

Claims an issue (GitHub) or work item (Azure DevOps): comments `Taken by <you>` on it, followed by the
running coding agent's session (the same `<Agent> Session: <id>` note every bdt comment gets: a
claude.ai link under a bridged Claude session, otherwise the bare session id; nothing outside an
agent), so others can see who is on it. `<you>` is the GitHub login
`gh` is authenticated as, else the git `user.name`. Without `NUMBER` it takes the issue the current
branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it refuses to guess if
that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.

## `bdt env get-keys`

Lists the variable **names** (never the values) defined in `.env`, `*.env` and `.env.*` files under the
current directory (recursively, skipping `node_modules`, `.git`, `.venv`, `.worktrees` and `.claude`) and directly in `~`, so you (or an agent) can see which variables exist without `cat .env`.
`--search TEXT` filters by key name (case-insensitive); `--no-home` leaves out the files in `~`. Files in `~` -- and any file using
`export VAR=VALUE` -- are bash syntax, so the output adds a `source <file>` hint. Exits 1 if nothing matches.

## `bdt translate`

One `translations.toml` is the source of truth for all UI translations; `bdt translate` generates the
per-language `de.json` / `en.json` / ... files from it. The generated files are build artifacts --
add them to `.gitignore` and run `bdt translate` in your `just install` / build / CI steps.

```toml
# translations.toml (keys with dots must be quoted when `nested = true`)
[ADD_BUTTON]
en = "Add"
de = "Hinzufügen"
fr = "Ajouter"
it = "Aggiungi"
server_only = true   # optional: never emitted into the JSON files (backend-only text)
```

```toml
# pyproject.toml
[tool.bdt.translate]
file = "translations.toml"             # default
languages = ["en", "de", "fr", "it"]   # default
required_languages = ["en"]            # default; `bdt translate add` fails without these
output = ["frontend/src/assets/i18n"]  # directories receiving <lng>.json
nested = false                         # true: "a.b.c" keys become nested JSON objects
scan = ["frontend/src"]                # t("KEY") / $t("KEY") in .ts/.tsx/.js/.jsx/.vue
scan_jinja = ["backend/print"]         # "KEY" | tr in .jinja2/.j2/.html
```

- A missing language falls back to `de`, then `en`, then the key itself.
- Keys used in code but absent from `translations.toml` are appended with an English placeholder and
  the command exits 1 without generating anything -- fill in the translations and rerun.
- `bdt translate add KEY en=Add de=Hinzufügen fr=Ajouter it=Aggiungi` adds a key to `translations.toml` and
  regenerates all JSON files in one go (the `required_languages` are mandatory; `--force` overwrites an existing key).
- `bdt translate --check` writes nothing and exits 1 if code uses keys missing from the toml (for CI).
- `bdt translate --import` merges existing `<lng>.json` files into `translations.toml` once, to migrate
  a repo that so far hand-maintained its JSON files; then `git rm --cached` them and ignore them.

## `bdt worktree`

```bash
bdt worktree my-feature [--base dev] [--env-file .local_env] [--no-submodules] [--install "just install"]
```

Creates `.worktrees/<name>` branched from `--base`, initializes submodules
(unless `--no-submodules`), and copies an env file into the new worktree as
`.env` (auto-detects `.local_env` then `.env` if `--env-file` isn't given).

## `bdt find-repo`

```bash
bdt find-repo my-repo [--root ~/projects] [--org MYORG] [--github-org MYORG] [--yes] [--pat PAT]
```

Finds a repo by name: first among the local clones under the work dir, then -- only if there is
no local match -- in an Azure DevOps org and/or a GitHub org. Names match exactly
(case-insensitive) if any repo has that name, otherwise by substring. Local matches are printed
as paths. A single remote-only match is offered for cloning (`--yes` skips the prompt; without a
TTY it never prompts and just tells you to pass `--yes`); several matches are listed and nothing
is cloned.

Clone destinations: `<root>/<project>/<repo>` for Azure DevOps, `<root>/github/<repo>` for GitHub.

Configuration (flags override env vars):

| Setting | Flag | Env vars | Default |
| --- | --- | --- | --- |
| Local work dir | `--root` | `AZDO_WORK_DIR`, `BMS_WORK_DIR` | `~/projects` (`C:/Projects` on Windows) |
| Azure DevOps org | `--org` | `AZDO_ORG`, `BMS_ORG` | none (ADO not searched) |
| GitHub org | `--github-org` | `GITHUB_ORG`, `BMS_GITHUB_ORG` | none (GitHub not searched) |
| ADO PAT for cloning | `--pat` | `AZURE_DEVOPS_EXT_PAT`, `AZURE_DEVOPS_PAT` | falls back to `az` login |

Set the org env vars in your shell profile to make `bdt find-repo <name>` work anywhere. It replaces
the `cross-repo-discovery` skill's `ALL_REPOS.md` sync. Needs `az` (ADO) / `gh` (GitHub) only when
those orgs are searched.

## `bdt cleanup worktrees` / `bdt cleanup orphaned-dbs` / `bdt cleanup db` / `bdt cleanup worktree`

```bash
bdt cleanup worktrees [root] [--remote origin] [--keep-dbs] [--yes]
bdt cleanup orphaned-dbs [root] [--include-caution] [--yes]
bdt cleanup db [path] [--confirm]
bdt cleanup worktree [path] [--keep-db] [--confirm]
```

The first two recursively scan every git repo under `root` (default: `.`)
for worktrees. `bdt cleanup worktrees` prunes the ones fully merged into
`<remote>/main`/`<remote>/test` (falling back to local `main`/`test` if no
such remote refs exist) — e.g. a tree of `.worktrees/<branch>` directories
accumulated across several repos over time. `bdt cleanup orphaned-dbs` has no
`--remote`/merge-status notion at all: it just finds pgdevkit test DBs with
no matching *live* git worktree, regardless of whether that worktree was
ever merged anywhere. Like `bdt issue delete`, neither command has an
interactive prompt — both only ever *print* what they would remove/drop;
pass `--yes` to actually do it.

`bdt cleanup db` / `bdt cleanup worktree` instead target one specific,
still-live worktree — `path` defaults to `.`, so both are meant to be run
from inside the worktree in question, regardless of its merge status.
`cleanup db` only drops that worktree's own pgdevkit test DB(s), leaving the
worktree itself alone; `cleanup worktree` removes the worktree too (and,
unless `--keep-db`, its DB(s) along with it) — refusing the main checkout, a
protected branch (`main`/`test`), a locked worktree, or a dirty one
(submodules included). Since these two act on a single worktree a human
picked out by hand, rather than scanning for candidates, they default to an
interactive `y/N` confirmation instead of `--yes`; pass `--confirm` to skip
it for non-interactive use.

Every one of these four commands, before dropping any database, additionally
requires typing `yes` at an interactive prompt whenever `--pg-host` isn't
`localhost`/`127.0.0.1`/`::1` — this specific check has no flag to bypass it
(not even `--yes`/`--confirm`), so a script or agent can never drop a
database on a shared/remote Postgres instance without a human confirming it
directly.

The DB-naming algorithm and orphan detection are entirely
[pgdevkit](https://github.com/bmsuisse/pgdevkit)'s own
(`pgdevkit.testdb.workspace_db_names()` /
`pgdevkit.testdb.find_orphaned_dbs()`) — this used to be a hand-rolled
reimplementation here (to avoid an import), which risked drifting out of
sync with pgdevkit's actual naming; now that pgdevkit exposes both directly,
bdt just calls them. If a repo's root `pyproject.toml` has a
`[tool.pgdevkit].engine = "postgres"` (the default once `[tool.pgdevkit]`
exists at all), removing one of its worktrees also drops the Postgres test
database(s) pgdevkit created for that branch — pass `--keep-dbs` to skip
that. A DB whose name ends in a bare branch name
(`main`/`test`/`dev`/`head`/`i18n`, rather than a slugified feature branch)
is flagged `⚠ possibly a standing reference DB` and excluded even with
`--yes`, since that might be an intentional baseline DB rather than an
orphaned leftover — pass `--include-caution` too if you've verified it
really is safe to drop. That flagging (bdt's own heuristic, not pgdevkit's)
is the one bit of naming-adjacent logic still here.

A repo can additionally own **sibling** test DBs (e.g. a second DB for a
vendored mock service) and **nested** ones (an unrelated per-branch DB, under
a different pgdevkit project name, that happens to share the same branch).
Siblings are pgdevkit's own concern now — configure
`[tool.pgdevkit].extra_db_suffixes` in the *consuming* repo (see pgdevkit's
README) and both `ensure_testdb`-side tooling and `bdt cleanup` pick it up
automatically. Nested projects have no pgdevkit equivalent (it's a wholly
separate project name/pyproject.toml, not a literal suffix of the same
project's DB), so that stays configured here:

```toml
[tool.bdt.worktree]
db_nested_projects = ["akeneo_editor"]  # a wholly separate per-branch DB,
                                         # its own (possibly section-less)
                                         # pyproject.toml, sharing this
                                         # worktree's branch
```

`--pg-port`/`--pg-user` (all four commands; env vars `PGPORT`/`PGUSER`, no
fallback to `$USER`/`$LOGNAME` — those are set in virtually every shell,
which would make the pgdevkit-user default below never fire) default to
*pgdevkit's own* test-container port/user, not the OS user or Postgres'
standard `5432` — `bdt cleanup orphaned-dbs`'s listing step always connects
via pgdevkit's own
`PGDEVKIT_TESTDB_*`-driven resolution (it's calling straight into pgdevkit),
so its own `psql`-based DROP step defaults to matching that, rather than
silently targeting a different Postgres instance than the one that was just
queried. Pass `--pg-port`/`--pg-user` explicitly if your setup deliberately
differs.

## `bdt commit`

```bash
bdt commit "feat(x): add widget support" file1.py file2.py [--json] [--no-verify] [--subrepo database]
```

Stages, commits, and pushes the given files. Pre-flight checks: files
exist, commit message follows [Conventional Commits](https://www.conventionalcommits.org)
(`type(scope): description`, skip with `--skip-message-check`), not on
`main`/`master` (skip with `--allow-main`). Retries once (re-`git add`) if a
pre-commit hook reformats files. Pass `--subrepo <dir>` (repeatable) for
repos that vendor a submodule (e.g. `database`) — files under that prefix
are committed/pushed inside the submodule first, then the bump is staged in
the parent repo.

The built-in commit types are `feat`, `fix`, `docs`, `style`, `refactor`,
`perf`, `test`, `build`, `ci`, `chore`, `revert`. A repo can accept
additional types on top of those under `[tool.bdt.commit]` in
`pyproject.toml`:

```toml
[tool.bdt.commit]
types = ["sql", "infra"]
```

Scope (the `(x)` in `feat(x): ...`) is unrestricted by default — any scope,
or none at all, is accepted. A repo can opt into restricting it to a fixed
list under the same table:

```toml
[tool.bdt.commit]
scopes = ["api", "ui", "db"]
```

Once configured, a message that *names* a scope must use one from the
list — but a message with no scope at all is still always accepted; this
doesn't make a scope mandatory.

If the pushed commit's type is `feat` and the current branch's PR is
already published (not a draft), it's converted back to draft — a feature
needs a fresh review pass before CI/merge, not just whatever review
happened before the commit existed. Run `bdt pr publish` when it's ready
again. Pass `--target`/`--pat` to resolve the PR on Azure DevOps (GitHub
always resolves the current branch's PR directly).

Set `IS_BMS_AI_SANDBOX=1` to skip the push step (commit only) — used when
an AI coding sandbox pushes on its own schedule separately. The draft
conversion above only runs after an actual push, so it's skipped in
sandbox mode too.

`--json` emits a machine-readable result for AI-agent callers:

```json
{
  "success": true, "committed": true, "pushed": true,
  "message": "...", "files": ["..."], "commit_sha": "abc1234",
  "error": null, "hint": null, "commit_type": "feat", "commit_scope": "x"
}
```

## `bdt logs roles` / `bdt logs tail`

Query Application Insights (KQL over `traces`/`exceptions`) via
`az monitor app-insights query`. No defaults are baked in — pass
`--resource-group`/`--app-insights` explicitly (or set
`AZURE_RESOURCE_GROUP`/`AZURE_APP_INSIGHTS`), since which Azure resource
"this repo" maps to isn't derivable from the git remote.

```bash
bdt logs roles --resource-group my-rg --app-insights my-app-insights --minutes 60
bdt logs tail --resource-group my-rg --app-insights my-app-insights --role my-service --level warning
```

## `bdt logs fetch`

Downloads the App Service log archive for a webapp/slot via
`az webapp log download`, unzips it, and writes every line matching a
common error/warning marker (`ERROR`, `CRITICAL`, `WARNING`, tracebacks,
`4xx`/`5xx`, `FAILED`, `FATAL`) to `<out>/<slot>_errors.log`. Simpler and
often preferable to the KQL commands above when you just want "what broke
recently" rather than a queryable trace stream.

The webapp/resource-group/slot come from a named environment configured in
the calling repo's `pyproject.toml`. `slot` is optional — omit it for an
app's default/production slot (no `--slot` is passed to `az`); set it for a
named deployment slot:

```toml
[tool.bdt.envs.prod]
webapp = "my-webapp"
resource_group = "my-rg"

[tool.bdt.envs.test]
webapp = "my-webapp"
resource_group = "my-rg"
slot = "test"
```

```bash
bdt logs fetch --env prod
bdt logs fetch --env prod --out logs/ --keep-archive
```

## `bdt lint`

Static checks (implementing [bmsuisse/skills#52](https://github.com/bmsuisse/skills/issues/52))
for the `postgres-best-practices` skill's SQL rules, pydantic-model placement, hand-wired HTTP in
TypeScript, and that the repo has its baseline tooling actually set up. Unused `.sql` files and
dead backend routes are a separate command, [`bdt dead-code`](#bdt-dead-code):

```bash
bdt lint                       # scan the current directory, recursively
bdt lint backend/              # scan one directory
bdt lint backend/db/a.py b.py  # scan only these files -- e.g. from a prek/pre-commit
                                # hook's staged-file list, so it can run on the diff only
bdt lint --no-tooling-check    # skip the tooling-config check for this run
```

Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and
`PostgresJsonResponse`, which only accept a literal string, sqlglot expression, `psycopg.sql` or t-string -- whose SQL argument can be resolved to a
literal or f-string/concatenation/`%`-format expression is checked (an
unresolvable argument, e.g. a plain function parameter, is silently skipped --
this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
it can't see through).

pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
`pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
recognised file-wide), so an unrelated `execute()` from another library, or one defined locally in a file without that
import, is never flagged. Aliased imports of the other helpers (`fetch_all as fa`) are recognised too. Not covered: a re-export through the
project's own module (`from app.db import execute`; call it as `db.execute(...)` or import it from `pgdevkit.db`
instead), a subclass of `PostgresJsonResponse` under another name, and CCMT2's pre-pgdevkit
`PostgresJsonResponse("postgres", sql)`, whose first argument is a connection source, not SQL.

- **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
  JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
  Simple `INSERT`/`UPDATE`/`DELETE` are exempt from the line limit; `INSERT ... SELECT`,
  `UPDATE ... FROM` and `DELETE ... USING` still count as complex.
  Also applied to any `*_sql`/`*_SQL` variable assigned a literal, even if it never reaches an
  `.execute()` call in the same file (e.g. it's handed to a helper).
- **`sql-fstring-injection`** / **`sql-concat-injection`** /
  **`sql-percent-format-injection`** / **`sql-format-injection`** — SQL built
  with an f-string, `+` concatenation, the `%` operator, or `str.format()`
  instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
  usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
  A dynamic fragment may be an identifier/expression, an optional clause, or a (possibly empty) list of
  assignments -- the statement is flagged if it parses as DML with any of these readings of its fragments.
- **`sql-sqlglot-string-injection`** — an f-string / concatenation / `%` / `.format()` string passed to a
  sqlglot builder that parses it as SQL (`select(...)`, `.from_()`, `.where()`, `.order_by()`, `parse_one()`, ...).
  The result is a sqlglot expression, which the rules above otherwise trust, so this is the hole they'd miss.
  Use `exp.column()`/`exp.to_identifier()` for names and `exp.Placeholder` + bound params for values.
- **`sql-positional-param`** — positional `%s` instead of named `%(name)s`.
- **`sql-forbidden-join`** — `RIGHT JOIN`/`LATERAL JOIN`/`CROSS APPLY` (same
  patterns the `prek` skill's `check_files.py` forbids in `.sql` files).

A candidate is only ever flagged once its (resolved) text actually parses with
[sqlglot](https://github.com/tobymao/sqlglot) as a `SELECT`/`INSERT`/`UPDATE`/
`DELETE`/`UNION`/`MERGE` — this is what lets `bdt lint` scan *any* `.execute()`
call, regardless of driver, without flagging e.g. a duckdb `COPY ... TO` export.

**`pydantic-model-misplaced`** — a pydantic model (or `PostgresTableModel`)
with more than 5 fields defined directly under an `api/` directory, instead of
an `api/models/`(/`schemas/`/`dto/`) module.

**`ts-handwired-http`** / **`ts-handwired-model`** (TypeScript, `.ts`/`.tsx`/`.mts`,
[bmsuisse/devtools#52](https://github.com/bmsuisse/devtools/issues/52)) — a frontend
package that already generates an API client from the backend's OpenAPI schema
(its nearest `package.json` depends on `openapi-typescript`/`openapi-fetch`,
`@hey-api/openapi-ts`, `orval`, ... or has an `openapi` script) should use it. The
rule flags hand-wired `fetch(...)`, `axios`/`axios.create(...)` and
`new XMLHttpRequest()` (`ts-handwired-http`), and a `.json()` result typed by
casting/annotating a hand-written model (`ts-handwired-model`, only reported
outside the block of a `fetch`/`axios` call, which the http rule already covers). Packages without a generator are skipped, as
are generated code (`generated/`, `*.gen.ts`, `*.generated.ts`, `api-types.ts`,
`*.d.ts`; also their `.mts`/`.tsx` variants), tests (`*.test.*`, `*.spec.*`, `__tests__/`, `e2e/`, `tests/`) and
third-party URLs (`fetch("https://...")`). Hand-wired access is fine for files and
other non-JSON traffic, since generators handle those badly, so a call is *not*
flagged when its enclosing block mentions `FormData`, `Blob`/`.blob()`/
`.arrayBuffer()`, `getReader()`/`response.body`/`TextDecoder`/`EventSource`/`SSE`,
`createObjectURL`, `new File(`, an `application/octet-stream`/`multipart/`/
`text/event-stream` content type, or returns `.text()` (a plain-text response), or sits in a `createClient(...)` setup (a `fetch` passed to the generated client).
It's a tokenizer-level heuristic, not a type-aware analysis; to silence a
legitimate exception, put a comment on (or right above) the line:

```ts
// bdt-lint: ignore ts-handwired-http -- websocket handshake, not in the schema
```

**Tooling config** — the repo must declare `ty`, `ruff` and `pytest` as
dependencies, have `pytest` configured (`[tool.pytest.ini_options]` or a
`pytest.ini`/`setup.cfg`), and have a `prek.toml` (see the `prek` skill).
Never a hard block — bypass it for one run with `--no-tooling-check`, or
permanently for the repo via `pyproject.toml`:

```toml
[tool.bdt.lint]
skip_tooling_check = true
```

Other `[tool.bdt.lint]` knobs (all optional): `exclude_dirs` (extra directory
names to skip, beyond the built-in `.venv`/`node_modules`/etc. list),
`pydantic_field_threshold` (default 5), `pydantic_base_classes` (default
`["BaseModel", "PostgresTableModel"]`), `pydantic_allowed_subdirs` (default
`["models", "schemas", "dto"]`), `pydantic_api_dir_names` (default `["api"]`),
`ts_exclude_globs` (repo-relative globs of TypeScript files to skip, e.g.
`["src/legacy/*"]`), `ts_non_json_markers` (extra strings that mark a call's
enclosing block as non-JSON traffic).

Requires [sqlglot](https://pypi.org/project/sqlglot/) for the SQL checks —
already pulled in transitively via `pgdevkit[db]`, but declared explicitly as
the `bmsdna-devtools[lint]` extra; a clear install hint is printed (not a raw
`ImportError`) if it's ever missing.

## `bdt dead-code`

Dead code a linter can't see, in one command and one config table, `[tool.bdt.dead_code]`:

- **`sql`** -- `.sql` files that no Python code loads;
- **`routes`** -- backend (FastAPI) routes that neither non-generated frontend code nor a `url_for(...)`
  call uses.

Each check runs when it is configured (`sql_roots`, resp. `[[tool.bdt.dead_code.apps]]`); `--only sql` /
`--only routes` (repeatable) narrows a run, e.g. to keep the slow one -- `routes` imports the app, so run
`bdt` with the repo's own interpreter (`uv run bdt dead-code`) -- out of a prek hook. Exit code is 0 (clean), 1
(findings) or 2 (setup problem: invalid config or pyproject.toml, nothing configured, the app doesn't import
within 5 minutes -- the error shows the import's output).

### `sql` -- unreferenced `.sql` files

A `.sql` file that no Python code loads is a query left behind after its caller was deleted or renamed. Name the
folders that hold *loadable* SQL (not schema/migration scripts, which are applied rather than loaded) and every
file under them that nothing references is reported (`sql-file-unreferenced`):

```toml
[tool.bdt.dead_code]
sql_roots = ["backend/db/queries"]            # repo-relative; a typo'd root is itself reported
sql_loader_functions = ["load_sql"]           # default; add your own loader's name if it differs
sql_unreferenced_ignore = ["backend/db/queries/legacy/*.sql"]   # globs for files reached some other way
# exclude_dirs = ["generated"]                # extra directory names to skip, beyond .venv/node_modules/etc.
```

A file counts as referenced by a literal `load_sql("topic", "name")` call (positional or `topic=`/`name=`;
topic = the file's parent directory, name = its stem); by a `load_sql("topic", some_var)` call in a Python file that
also contains the stem as a string literal (so `name = "a" if x else "b"` and lookup tables work);
by a string literal that is a path whose trailing segments equal the file's repo-relative path
(`get_sql_with_prm_list("backend/api/sql/x.sql")`); or by its bare filename as a literal in a
Python file under the SQL folder's parent (`_SQL_DIR / "x.sql"`). It never executes code, so a
file reached through a fully computed path is a false positive -- list it in
`sql_unreferenced_ignore`. References are searched across the whole repo, since the caller can live anywhere.
A Python file that can't be parsed (including syntax newer than the interpreter running `bdt`) is itself
reported as `sql-check-python-unparseable`, because references in it are unknown.

### `routes` -- backend routes nobody uses

Backend (FastAPI) operations that **no non-generated frontend code calls and no backend code references by
name** -- dead routes that still have to be maintained, secured and tested.

```toml
[[tool.bdt.dead_code.apps]]
name = "akeneo"                                   # label used in the report
app = "main_app:app"                              # module:attribute, imported in a subprocess ...
app_dir = "akeneo_editor/backend"                 # ... with this directory (repo-relative) as cwd/import root
# openapi = "frontend/openapi.json"               # alternative to `app`: a committed OpenAPI document
frontends = ["akeneo_editor/frontend/src"]        # repo-relative dirs or globs, e.g. "mdmapp/app/react_apps/*/src"
exclude_prefixes = ["/external_api"]              # routes meant for other callers (external API, webhooks)
exclude_tags = ["agent"]                          # e.g. LLM/MCP tools
exclude_paths = ["/auth/*", "GET /health"]        # fnmatch globs on "/path" or "METHOD /path"
baseline = "dead-code-baseline.txt"               # optional ratchet, see below
# env = { SOME_REQUIRED_SETTING = "x" }           # extra environment for importing the app
# exclude_frontend_globs = ["src/legacy/*"]       # repo-relative frontend files to ignore as callers
# url_for_functions = ["url_for", "url_path_for"] # default; functions whose first argument names a route
```

Pair each backend app with *its own* frontends (one `[[...apps]]` entry per backend) -- a route
called only by another backend's frontend is still unused here.

The backend inventory is `app.openapi()` of the app **and of every mounted sub-app** (with the
mount prefix), so it needs no committed schema and can't go stale; routes with
`include_in_schema=False` are not considered.

**Generated code never counts as a caller.** A generated client lists *every* route, so it is
skipped (`generated/`, `*.gen.*`, `*.generated.*`, `api-types*`, `openapi_schema*`, `*.d.ts`), as are
tests and e2e specs (`*.test.*`, `*.spec.*`, `__tests__/`, `tests/`, `e2e/`) -- for TypeScript,
JavaScript and Vue files alike. Comments are blanked before scanning. An operation is called when
non-generated code of one of the app's frontends has (strongest first):

- **sdk** -- a reference to a hey-api SDK function (read from the `sdk.gen.ts` under that same frontend
  directory, matched on method *and* url) or one of its react-query helpers (`fooOptions`, `fooMutation`,
  `fooQueryKey`, ...). Each `frontends` directory is scoped to its own SDK, so two apps that both generate
  `listItems` aren't confused;
- **fetch** -- an openapi-fetch call `.GET("/path"` naming this method and path (a `DELETE` of the
  same path with no call of its own is still reported);
- **url** -- a string/template literal equal to the path template (any method), e.g. a hand-written
  `fetch(`/api/x/${id}`)`, `"/api/x/" + id` or an `<a href=...>` -- nested templates like
  `${qs ? `?a=${b}` : ""}` are handled;
- **url-sfx** -- a literal (two or more segments) that is a suffix of the route (a client with a base URL,
  or a `${base}` prefix).

**Routes the backend refers to by name are used, too.** Auth redirects and OAuth callbacks have no frontend caller
by nature -- the backend builds their URL: `request.url_for("auth_callback")`, `app.url_path_for("login")`,
`{{ url_for('login') }}` in a template. A string literal passed (first argument, or `name=`) to one of
`url_for_functions` in a non-test Python file or Jinja-style template (`.html`, `.jinja`, `.jinja2`, `.j2`) under the app's `app_dir`
marks the route of that name as used (`"mount:name"` for a mounted sub-app counts as `name`). The route name is recovered from
its `operationId`: FastAPI's default `name + path + method` form, a bare `name`, or `<prefix>-<name>` from a custom
`generate_unique_id`; an OpenAPI file without operation ids can't be matched this way. It is a text match, so a call
in a comment counts too.

It is a heuristic: no type information, a URL literal matches every method of that path (and so does an
SPA `<Link to="/users/${id}">`), and URLs assembled from non-literal pieces (including a non-literal route
name in `url_for`) or handed to the client by the server are invisible -- exclude those routes explicitly. An app
that exposes no documented operations (e.g. one wrapped in middleware that hides `openapi()`) is a setup error,
not a clean pass. Run against our repos, the usual legitimate exclusions are health checks, the SPA
catch-all, service-to-service endpoints and an external API folder.

**Adopting it without fixing everything first:** set `baseline`, run
`uv run bdt dead-code --update-baseline` once (it concerns the `routes` check only), and commit the file. From
then on only *new* unused routes fail -- and a baseline line whose route has since been deleted, excluded or
used is reported as `api-route-baseline-stale`, so the list can only shrink.

## `bdt find-injection`

Scans backend and frontend code for injection risks, and warns when a web project has no
Content-Security-Policy.

```bash
bdt find-injection [PATHS...]            # files/folders, default: current directory
bdt find-injection --diff [--base main]  # only files changed on this branch + uncommitted/untracked
bdt find-injection --strict              # also fail on "review" items
```

Findings have two severities: **error** (a definite unsafe pattern, exit code 1) and **review**
(depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
code 0 unless `--strict`).

- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
  SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
  a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
  call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
  reports unverified calls.)
- **Other Python sinks**: `eval`/`exec`, `os.system`, `subprocess(..., shell=True)`, `yaml.load`
  without a safe loader, `pickle.loads`, `Markup()`/`mark_safe()`, Jinja `autoescape=False`,
  `render_template_string`.
- **Frontend** (TS/JS/JSX/Vue/Svelte/HTML): `innerHTML`/`outerHTML`/`insertAdjacentHTML`,
  `dangerouslySetInnerHTML`, `v-html`, `document.write`, `eval`, `new Function`, string
  `setTimeout`/`setInterval`, `javascript:` URLs, `postMessage(..., "*")`, `srcdoc`, and `<iframe>`
  without `sandbox` (or with `allow-scripts` + `allow-same-origin`).
- **CSP**: `csp-missing` when a web project (frontend files or a Python web framework) has no
  `Content-Security-Policy` anywhere in the repo (header in code, `<meta http-equiv>`,
  `staticwebapp.config.json`, nginx/web.config, ...); `csp-weakened` for `'unsafe-inline'`,
  `'unsafe-eval'` or a wildcard `script-src`. The CSP lookup always covers the whole repo, even with `--diff`.

Skipped automatically: test files, generated code, `*.min.js` files, the usual build/vendor directories (`node_modules`, `.venv`, `dist`, ...) and,
inside a git repo, anything ignored by `.gitignore` (pass `--no-gitignore` to scan it anyway).
Add more with `--exclude` (repeatable; a directory name at any depth, a root-relative path, or a glob
such as `'assets/**/*.js'`) or `[tool.bdt.lint] exclude_dirs = [...]` in `pyproject.toml`.
Identical findings on the same line are reported once. Silence a confirmed-safe finding with
`# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.

## Releasing

Bump `version` in `pyproject.toml` as part of your PR, same as any other
change. Once that PR merges to `main` and the `Python Test` workflow passes
for that commit, `.github/workflows/auto-release.yml` automatically tags it
`vX.Y.Z`, cuts a GitHub Release (skipping if that version was already
released, e.g. a merge that didn't touch the version), and dispatches
`python-publish.yml` to publish it to PyPI — no manual release step, and no
extra secret to configure. Two non-obvious GitHub Actions quirks shaped
this (see the comments at the top of `auto-release.yml` for the full
reasoning, since both were hit and confirmed the hard way):

- A release created with the default `GITHUB_TOKEN` does **not** trigger
  other workflows' `release: published` listeners (an anti-recursion
  safeguard) — `workflow_dispatch` is the documented exception, so
  `auto-release.yml` dispatches `python-publish.yml` directly (`gh workflow
  run`) instead of relying on the release to cascade into it.
- `python-publish.yml` deliberately stays a plain, directly-triggered
  top-level workflow rather than something `auto-release.yml` calls via
  `workflow_call`: PyPI's OIDC trusted publishing does not support
  reusable/called workflows and silently rejects the token in that shape.

`workflow_dispatch` (or an actual GitHub UI release) on `python-publish.yml`
still works as a manual fallback if you ever need to re-publish a version
without going through `auto-release.yml`.
