bmsdna-devtools 0.24.0__tar.gz → 0.26.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/PKG-INFO +152 -8
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/README.md +149 -7
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/ado_issue.py +31 -2
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/cli.py +215 -36
- bmsdna_devtools-0.26.0/bmsdna/devtools/find_injection.py +310 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/gh_pr.py +50 -21
- bmsdna_devtools-0.26.0/bmsdna/devtools/injection_frontend.py +237 -0
- bmsdna_devtools-0.26.0/bmsdna/devtools/injection_python.py +189 -0
- bmsdna_devtools-0.26.0/bmsdna/devtools/lint.py +183 -0
- bmsdna_devtools-0.26.0/bmsdna/devtools/lint_findings.py +30 -0
- bmsdna_devtools-0.26.0/bmsdna/devtools/lint_models.py +104 -0
- bmsdna_devtools-0.26.0/bmsdna/devtools/lint_sql.py +500 -0
- bmsdna_devtools-0.26.0/bmsdna/devtools/lint_tooling.py +86 -0
- bmsdna_devtools-0.26.0/bmsdna/devtools/lint_typescript.py +394 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_build.py +62 -12
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/pyproject.toml +9 -1
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_ado_issue_flow.py +54 -0
- bmsdna_devtools-0.26.0/tests/test_cli_issue.py +335 -0
- bmsdna_devtools-0.26.0/tests/test_cli_pr_id.py +234 -0
- bmsdna_devtools-0.26.0/tests/test_find_injection.py +238 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_gh_pr.py +155 -3
- bmsdna_devtools-0.26.0/tests/test_lint.py +97 -0
- bmsdna_devtools-0.26.0/tests/test_lint_models.py +125 -0
- bmsdna_devtools-0.26.0/tests/test_lint_sql.py +296 -0
- bmsdna_devtools-0.26.0/tests/test_lint_tooling.py +83 -0
- bmsdna_devtools-0.26.0/tests/test_lint_typescript.py +286 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_pr_build.py +34 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_pr_build_flow.py +61 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/uv.lock +8 -1
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/.gitignore +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/.python-version +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/justfile +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_worktree_clean.py +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bmsdna-devtools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.26.0
|
|
4
4
|
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: pgdevkit[db]>=0.7.1
|
|
7
7
|
Requires-Dist: requests>=2.32.0
|
|
8
8
|
Requires-Dist: rich>=13.0.0
|
|
9
9
|
Requires-Dist: typer>=0.26.7
|
|
10
|
+
Provides-Extra: lint
|
|
11
|
+
Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
|
|
10
12
|
Description-Content-Type: text/markdown
|
|
11
13
|
|
|
12
14
|
# bmsdna-devtools
|
|
@@ -14,7 +16,9 @@ Description-Content-Type: text/markdown
|
|
|
14
16
|
Shared developer tooling for BMS projects: PR build/check status, PR
|
|
15
17
|
creation, issue/work item creation and comments, git worktrees (creation and
|
|
16
18
|
merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
|
|
17
|
-
pre-flight checks,
|
|
19
|
+
pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
|
|
20
|
+
postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
21
|
+
TypeScript, and baseline tooling.
|
|
18
22
|
`bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
|
|
19
23
|
remote is Azure DevOps or GitHub and use `az`/`gh` accordingly.
|
|
20
24
|
Consolidates near-duplicate scripts that used to be copy-pasted across
|
|
@@ -75,6 +79,12 @@ is shown in the output. Check status is computed from
|
|
|
75
79
|
`gh pr view --json statusCheckRollup` rather than `gh pr checks --json`,
|
|
76
80
|
since the latter flag isn't available in all `gh` releases.
|
|
77
81
|
|
|
82
|
+
Pass `--pr-id <number>` to check a specific PR directly instead of resolving
|
|
83
|
+
it from the current branch — useful when that PR's branch isn't checked out
|
|
84
|
+
locally at all. `bdt pr retry`, `pr publish`, `pr update`, and `pr comment`
|
|
85
|
+
all accept the same option, for the same reason. (`pr watch-deploy` doesn't:
|
|
86
|
+
it watches a branch-triggered build/workflow run, not any particular PR.)
|
|
87
|
+
|
|
78
88
|
## `bdt pr retry`
|
|
79
89
|
|
|
80
90
|
Retry only the **failed** job(s)/stage(s) of the most recent build/run for the
|
|
@@ -86,7 +96,8 @@ bdt pr retry [--target-branch main]
|
|
|
86
96
|
```
|
|
87
97
|
|
|
88
98
|
No build/run ID needed — like `bdt pr status`, it resolves the PR (and its
|
|
89
|
-
latest build/run per pipeline/workflow) from the current branch
|
|
99
|
+
latest build/run per pipeline/workflow) from the current branch, or from
|
|
100
|
+
`--pr-id` directly if given.
|
|
90
101
|
|
|
91
102
|
**Azure DevOps**: uses the `retry=true` query parameter on the "Update
|
|
92
103
|
Build" REST API
|
|
@@ -215,10 +226,20 @@ Destructive commands (`issue delete`, `issue comment delete`) require an
|
|
|
215
226
|
explicit `--yes` — there's no interactive confirmation prompt, since `bdt` is
|
|
216
227
|
also invoked by AI-agent callers that can't answer one.
|
|
217
228
|
|
|
229
|
+
`--tag` and `--label` are aliases of each other (repeatable on `create`,
|
|
230
|
+
`search`, and `update`) — pass whichever reads naturally; the values are
|
|
231
|
+
merged and routed to tags on Azure DevOps or labels on GitHub, whichever
|
|
232
|
+
backend is actually active, instead of being silently ignored by the other
|
|
233
|
+
one. Same for `update`'s `--remove-tag`/`--remove-label`.
|
|
234
|
+
|
|
218
235
|
**Azure DevOps**: `--type` selects the work item type on `create` (`Bug`,
|
|
219
236
|
`Task`, `User Story`, ... — whatever the project's process defines; default
|
|
220
|
-
`Bug`). `--tag`
|
|
221
|
-
`update` to leave tags unchanged).
|
|
237
|
+
`Bug`). `--tag`/`--label` set/replace the full tag list (repeatable; omit
|
|
238
|
+
both on `update` to leave tags unchanged). `update`'s `--remove-tag`/
|
|
239
|
+
`--remove-label` instead removes just the named tag(s) and leaves the rest —
|
|
240
|
+
unlike `--tag`/`--label`, which replace the whole set — by reading the work
|
|
241
|
+
item's current tags first (there's no Azure DevOps "remove one tag" patch
|
|
242
|
+
op). `--state` (`update` only) sets
|
|
222
243
|
`System.State`, e.g. `Active`, `Resolved`, `Closed`. `--screenshot` uploads
|
|
223
244
|
each image as a work item attachment (visible in the Attachments tab) and
|
|
224
245
|
posts a comment embedding them inline with Markdown — the Description field
|
|
@@ -242,9 +263,9 @@ never falls back to `pyproject.toml`, so an unrelated field update (e.g.
|
|
|
242
263
|
just `--title`) can't silently relocate the item to a different board.
|
|
243
264
|
|
|
244
265
|
**GitHub**: a thin wrapper around `gh issue create` / `edit` / `delete` /
|
|
245
|
-
`comment`. `--label`
|
|
246
|
-
`update` (paired with `--remove-label`); labels must already
|
|
247
|
-
repo. `--screenshot` pushes images to a `pr-assets` branch (same trick `bdt
|
|
266
|
+
`comment`. `--label`/`--tag` add a label on `create`, or add/remove one on
|
|
267
|
+
`update` (paired with `--remove-label`/`--remove-tag`); labels must already
|
|
268
|
+
exist in the repo. `--screenshot` pushes images to a `pr-assets` branch (same trick `bdt
|
|
248
269
|
pr create --screenshot` uses, since GitHub has no API for uploading an image
|
|
249
270
|
into an issue) and appends them to the issue body / comment as Markdown.
|
|
250
271
|
`issue delete` is **permanent** — GitHub has no recycle bin for issues.
|
|
@@ -466,6 +487,129 @@ bdt logs fetch --env prod
|
|
|
466
487
|
bdt logs fetch --env prod --out logs/ --keep-archive
|
|
467
488
|
```
|
|
468
489
|
|
|
490
|
+
## `bdt lint`
|
|
491
|
+
|
|
492
|
+
Static checks (implementing [bmsuisse/skills#52](https://github.com/bmsuisse/skills/issues/52))
|
|
493
|
+
for the `postgres-best-practices` skill's SQL rules, pydantic-model placement, and
|
|
494
|
+
that the repo has its baseline tooling actually set up:
|
|
495
|
+
|
|
496
|
+
```bash
|
|
497
|
+
bdt lint # scan the current directory, recursively
|
|
498
|
+
bdt lint backend/ # scan one directory
|
|
499
|
+
bdt lint backend/db/a.py b.py # scan only these files -- e.g. from a prek/pre-commit
|
|
500
|
+
# hook's staged-file list, so it can run on the diff only
|
|
501
|
+
bdt lint --no-tooling-check # skip the tooling-config check for this run
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Every `.execute()`/`.executemany()` call whose SQL argument can be resolved to a
|
|
505
|
+
literal or f-string/concatenation/`%`-format expression is checked (an
|
|
506
|
+
unresolvable argument, e.g. a plain function parameter, is silently skipped --
|
|
507
|
+
this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
|
|
508
|
+
it can't see through):
|
|
509
|
+
|
|
510
|
+
- **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
|
|
511
|
+
JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
|
|
512
|
+
- **`sql-fstring-injection`** / **`sql-concat-injection`** /
|
|
513
|
+
**`sql-percent-format-injection`** / **`sql-format-injection`** — SQL built
|
|
514
|
+
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
515
|
+
instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params.
|
|
516
|
+
- **`sql-positional-param`** — positional `%s` instead of named `%(name)s`.
|
|
517
|
+
- **`sql-forbidden-join`** — `RIGHT JOIN`/`LATERAL JOIN`/`CROSS APPLY` (same
|
|
518
|
+
patterns the `prek` skill's `check_files.py` forbids in `.sql` files).
|
|
519
|
+
|
|
520
|
+
A candidate is only ever flagged once its (resolved) text actually parses with
|
|
521
|
+
[sqlglot](https://github.com/tobymao/sqlglot) as a `SELECT`/`INSERT`/`UPDATE`/
|
|
522
|
+
`DELETE`/`UNION`/`MERGE` — this is what lets `bdt lint` scan *any* `.execute()`
|
|
523
|
+
call, regardless of driver, without flagging e.g. a duckdb `COPY ... TO` export.
|
|
524
|
+
|
|
525
|
+
**`pydantic-model-misplaced`** — a pydantic model (or `PostgresTableModel`)
|
|
526
|
+
with more than 5 fields defined directly under an `api/` directory, instead of
|
|
527
|
+
an `api/models/`(/`schemas/`/`dto/`) module.
|
|
528
|
+
|
|
529
|
+
**`ts-handwired-http`** / **`ts-handwired-model`** (TypeScript, `.ts`/`.tsx`/`.mts`,
|
|
530
|
+
[bmsuisse/devtools#52](https://github.com/bmsuisse/devtools/issues/52)) — a frontend
|
|
531
|
+
package that already generates an API client from the backend's OpenAPI schema
|
|
532
|
+
(its nearest `package.json` depends on `openapi-typescript`/`openapi-fetch`,
|
|
533
|
+
`@hey-api/openapi-ts`, `orval`, ... or has an `openapi` script) should use it. The
|
|
534
|
+
rule flags hand-wired `fetch(...)`, `axios`/`axios.create(...)` and
|
|
535
|
+
`new XMLHttpRequest()` (`ts-handwired-http`), and a `.json()` result typed by
|
|
536
|
+
casting/annotating a hand-written model (`ts-handwired-model`, only reported
|
|
537
|
+
outside the block of a `fetch`/`axios` call, which the http rule already covers). Packages without a generator are skipped, as
|
|
538
|
+
are generated code (`generated/`, `*.gen.ts`, `*.generated.ts`, `api-types.ts`,
|
|
539
|
+
`*.d.ts`; also their `.mts`/`.tsx` variants), tests (`*.test.*`, `*.spec.*`, `__tests__/`, `e2e/`, `tests/`) and
|
|
540
|
+
third-party URLs (`fetch("https://...")`). Hand-wired access is fine for files and
|
|
541
|
+
other non-JSON traffic, since generators handle those badly, so a call is *not*
|
|
542
|
+
flagged when its enclosing block mentions `FormData`, `Blob`/`.blob()`/
|
|
543
|
+
`.arrayBuffer()`, `getReader()`/`response.body`/`TextDecoder`/`EventSource`/`SSE`,
|
|
544
|
+
`createObjectURL`, `new File(`, an `application/octet-stream`/`multipart/`/
|
|
545
|
+
`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).
|
|
546
|
+
It's a tokenizer-level heuristic, not a type-aware analysis; to silence a
|
|
547
|
+
legitimate exception, put a comment on (or right above) the line:
|
|
548
|
+
|
|
549
|
+
```ts
|
|
550
|
+
// bdt-lint: ignore ts-handwired-http -- websocket handshake, not in the schema
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
**Tooling config** — the repo must declare `ty`, `ruff` and `pytest` as
|
|
554
|
+
dependencies, have `pytest` configured (`[tool.pytest.ini_options]` or a
|
|
555
|
+
`pytest.ini`/`setup.cfg`), and have a `prek.toml` (see the `prek` skill).
|
|
556
|
+
Never a hard block — bypass it for one run with `--no-tooling-check`, or
|
|
557
|
+
permanently for the repo via `pyproject.toml`:
|
|
558
|
+
|
|
559
|
+
```toml
|
|
560
|
+
[tool.bdt.lint]
|
|
561
|
+
skip_tooling_check = true
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Other `[tool.bdt.lint]` knobs (all optional): `exclude_dirs` (extra directory
|
|
565
|
+
names to skip, beyond the built-in `.venv`/`node_modules`/etc. list),
|
|
566
|
+
`pydantic_field_threshold` (default 5), `pydantic_base_classes` (default
|
|
567
|
+
`["BaseModel", "PostgresTableModel"]`), `pydantic_allowed_subdirs` (default
|
|
568
|
+
`["models", "schemas", "dto"]`), `pydantic_api_dir_names` (default `["api"]`),
|
|
569
|
+
`ts_exclude_globs` (repo-relative globs of TypeScript files to skip, e.g.
|
|
570
|
+
`["src/legacy/*"]`), `ts_non_json_markers` (extra strings that mark a call's
|
|
571
|
+
enclosing block as non-JSON traffic).
|
|
572
|
+
|
|
573
|
+
Requires [sqlglot](https://pypi.org/project/sqlglot/) for the SQL checks —
|
|
574
|
+
already pulled in transitively via `pgdevkit[db]`, but declared explicitly as
|
|
575
|
+
the `bmsdna-devtools[lint]` extra; a clear install hint is printed (not a raw
|
|
576
|
+
`ImportError`) if it's ever missing.
|
|
577
|
+
|
|
578
|
+
## `bdt find-injection`
|
|
579
|
+
|
|
580
|
+
Scans backend and frontend code for injection risks, and warns when a web project has no
|
|
581
|
+
Content-Security-Policy.
|
|
582
|
+
|
|
583
|
+
```bash
|
|
584
|
+
bdt find-injection [PATHS...] # files/folders, default: current directory
|
|
585
|
+
bdt find-injection --diff [--base main] # only files changed on this branch + uncommitted/untracked
|
|
586
|
+
bdt find-injection --strict # also fail on "review" items
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Findings have two severities: **error** (a definite unsafe pattern, exit code 1) and **review**
|
|
590
|
+
(depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
|
|
591
|
+
code 0 unless `--strict`).
|
|
592
|
+
|
|
593
|
+
- **SQL** (Python `.execute()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
594
|
+
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
|
|
595
|
+
a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
|
|
596
|
+
call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
|
|
597
|
+
reports unverified calls.)
|
|
598
|
+
- **Other Python sinks**: `eval`/`exec`, `os.system`, `subprocess(..., shell=True)`, `yaml.load`
|
|
599
|
+
without a safe loader, `pickle.loads`, `Markup()`/`mark_safe()`, Jinja `autoescape=False`,
|
|
600
|
+
`render_template_string`.
|
|
601
|
+
- **Frontend** (TS/JS/JSX/Vue/Svelte/HTML): `innerHTML`/`outerHTML`/`insertAdjacentHTML`,
|
|
602
|
+
`dangerouslySetInnerHTML`, `v-html`, `document.write`, `eval`, `new Function`, string
|
|
603
|
+
`setTimeout`/`setInterval`, `javascript:` URLs, `postMessage(..., "*")`, `srcdoc`, and `<iframe>`
|
|
604
|
+
without `sandbox` (or with `allow-scripts` + `allow-same-origin`).
|
|
605
|
+
- **CSP**: `csp-missing` when a web project (frontend files or a Python web framework) has no
|
|
606
|
+
`Content-Security-Policy` anywhere in the repo (header in code, `<meta http-equiv>`,
|
|
607
|
+
`staticwebapp.config.json`, nginx/web.config, ...); `csp-weakened` for `'unsafe-inline'`,
|
|
608
|
+
`'unsafe-eval'` or a wildcard `script-src`. The CSP lookup always covers the whole repo, even with `--diff`.
|
|
609
|
+
|
|
610
|
+
Test files and generated code are skipped. Silence a confirmed-safe finding with
|
|
611
|
+
`# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
|
|
612
|
+
|
|
469
613
|
## Releasing
|
|
470
614
|
|
|
471
615
|
Bump `version` in `pyproject.toml` as part of your PR, same as any other
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
Shared developer tooling for BMS projects: PR build/check status, PR
|
|
4
4
|
creation, issue/work item creation and comments, git worktrees (creation and
|
|
5
5
|
merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
|
|
6
|
-
pre-flight checks,
|
|
6
|
+
pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
|
|
7
|
+
postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
8
|
+
TypeScript, and baseline tooling.
|
|
7
9
|
`bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
|
|
8
10
|
remote is Azure DevOps or GitHub and use `az`/`gh` accordingly.
|
|
9
11
|
Consolidates near-duplicate scripts that used to be copy-pasted across
|
|
@@ -64,6 +66,12 @@ is shown in the output. Check status is computed from
|
|
|
64
66
|
`gh pr view --json statusCheckRollup` rather than `gh pr checks --json`,
|
|
65
67
|
since the latter flag isn't available in all `gh` releases.
|
|
66
68
|
|
|
69
|
+
Pass `--pr-id <number>` to check a specific PR directly instead of resolving
|
|
70
|
+
it from the current branch — useful when that PR's branch isn't checked out
|
|
71
|
+
locally at all. `bdt pr retry`, `pr publish`, `pr update`, and `pr comment`
|
|
72
|
+
all accept the same option, for the same reason. (`pr watch-deploy` doesn't:
|
|
73
|
+
it watches a branch-triggered build/workflow run, not any particular PR.)
|
|
74
|
+
|
|
67
75
|
## `bdt pr retry`
|
|
68
76
|
|
|
69
77
|
Retry only the **failed** job(s)/stage(s) of the most recent build/run for the
|
|
@@ -75,7 +83,8 @@ bdt pr retry [--target-branch main]
|
|
|
75
83
|
```
|
|
76
84
|
|
|
77
85
|
No build/run ID needed — like `bdt pr status`, it resolves the PR (and its
|
|
78
|
-
latest build/run per pipeline/workflow) from the current branch
|
|
86
|
+
latest build/run per pipeline/workflow) from the current branch, or from
|
|
87
|
+
`--pr-id` directly if given.
|
|
79
88
|
|
|
80
89
|
**Azure DevOps**: uses the `retry=true` query parameter on the "Update
|
|
81
90
|
Build" REST API
|
|
@@ -204,10 +213,20 @@ Destructive commands (`issue delete`, `issue comment delete`) require an
|
|
|
204
213
|
explicit `--yes` — there's no interactive confirmation prompt, since `bdt` is
|
|
205
214
|
also invoked by AI-agent callers that can't answer one.
|
|
206
215
|
|
|
216
|
+
`--tag` and `--label` are aliases of each other (repeatable on `create`,
|
|
217
|
+
`search`, and `update`) — pass whichever reads naturally; the values are
|
|
218
|
+
merged and routed to tags on Azure DevOps or labels on GitHub, whichever
|
|
219
|
+
backend is actually active, instead of being silently ignored by the other
|
|
220
|
+
one. Same for `update`'s `--remove-tag`/`--remove-label`.
|
|
221
|
+
|
|
207
222
|
**Azure DevOps**: `--type` selects the work item type on `create` (`Bug`,
|
|
208
223
|
`Task`, `User Story`, ... — whatever the project's process defines; default
|
|
209
|
-
`Bug`). `--tag`
|
|
210
|
-
`update` to leave tags unchanged).
|
|
224
|
+
`Bug`). `--tag`/`--label` set/replace the full tag list (repeatable; omit
|
|
225
|
+
both on `update` to leave tags unchanged). `update`'s `--remove-tag`/
|
|
226
|
+
`--remove-label` instead removes just the named tag(s) and leaves the rest —
|
|
227
|
+
unlike `--tag`/`--label`, which replace the whole set — by reading the work
|
|
228
|
+
item's current tags first (there's no Azure DevOps "remove one tag" patch
|
|
229
|
+
op). `--state` (`update` only) sets
|
|
211
230
|
`System.State`, e.g. `Active`, `Resolved`, `Closed`. `--screenshot` uploads
|
|
212
231
|
each image as a work item attachment (visible in the Attachments tab) and
|
|
213
232
|
posts a comment embedding them inline with Markdown — the Description field
|
|
@@ -231,9 +250,9 @@ never falls back to `pyproject.toml`, so an unrelated field update (e.g.
|
|
|
231
250
|
just `--title`) can't silently relocate the item to a different board.
|
|
232
251
|
|
|
233
252
|
**GitHub**: a thin wrapper around `gh issue create` / `edit` / `delete` /
|
|
234
|
-
`comment`. `--label`
|
|
235
|
-
`update` (paired with `--remove-label`); labels must already
|
|
236
|
-
repo. `--screenshot` pushes images to a `pr-assets` branch (same trick `bdt
|
|
253
|
+
`comment`. `--label`/`--tag` add a label on `create`, or add/remove one on
|
|
254
|
+
`update` (paired with `--remove-label`/`--remove-tag`); labels must already
|
|
255
|
+
exist in the repo. `--screenshot` pushes images to a `pr-assets` branch (same trick `bdt
|
|
237
256
|
pr create --screenshot` uses, since GitHub has no API for uploading an image
|
|
238
257
|
into an issue) and appends them to the issue body / comment as Markdown.
|
|
239
258
|
`issue delete` is **permanent** — GitHub has no recycle bin for issues.
|
|
@@ -455,6 +474,129 @@ bdt logs fetch --env prod
|
|
|
455
474
|
bdt logs fetch --env prod --out logs/ --keep-archive
|
|
456
475
|
```
|
|
457
476
|
|
|
477
|
+
## `bdt lint`
|
|
478
|
+
|
|
479
|
+
Static checks (implementing [bmsuisse/skills#52](https://github.com/bmsuisse/skills/issues/52))
|
|
480
|
+
for the `postgres-best-practices` skill's SQL rules, pydantic-model placement, and
|
|
481
|
+
that the repo has its baseline tooling actually set up:
|
|
482
|
+
|
|
483
|
+
```bash
|
|
484
|
+
bdt lint # scan the current directory, recursively
|
|
485
|
+
bdt lint backend/ # scan one directory
|
|
486
|
+
bdt lint backend/db/a.py b.py # scan only these files -- e.g. from a prek/pre-commit
|
|
487
|
+
# hook's staged-file list, so it can run on the diff only
|
|
488
|
+
bdt lint --no-tooling-check # skip the tooling-config check for this run
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Every `.execute()`/`.executemany()` call whose SQL argument can be resolved to a
|
|
492
|
+
literal or f-string/concatenation/`%`-format expression is checked (an
|
|
493
|
+
unresolvable argument, e.g. a plain function parameter, is silently skipped --
|
|
494
|
+
this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
|
|
495
|
+
it can't see through):
|
|
496
|
+
|
|
497
|
+
- **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
|
|
498
|
+
JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
|
|
499
|
+
- **`sql-fstring-injection`** / **`sql-concat-injection`** /
|
|
500
|
+
**`sql-percent-format-injection`** / **`sql-format-injection`** — SQL built
|
|
501
|
+
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
502
|
+
instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params.
|
|
503
|
+
- **`sql-positional-param`** — positional `%s` instead of named `%(name)s`.
|
|
504
|
+
- **`sql-forbidden-join`** — `RIGHT JOIN`/`LATERAL JOIN`/`CROSS APPLY` (same
|
|
505
|
+
patterns the `prek` skill's `check_files.py` forbids in `.sql` files).
|
|
506
|
+
|
|
507
|
+
A candidate is only ever flagged once its (resolved) text actually parses with
|
|
508
|
+
[sqlglot](https://github.com/tobymao/sqlglot) as a `SELECT`/`INSERT`/`UPDATE`/
|
|
509
|
+
`DELETE`/`UNION`/`MERGE` — this is what lets `bdt lint` scan *any* `.execute()`
|
|
510
|
+
call, regardless of driver, without flagging e.g. a duckdb `COPY ... TO` export.
|
|
511
|
+
|
|
512
|
+
**`pydantic-model-misplaced`** — a pydantic model (or `PostgresTableModel`)
|
|
513
|
+
with more than 5 fields defined directly under an `api/` directory, instead of
|
|
514
|
+
an `api/models/`(/`schemas/`/`dto/`) module.
|
|
515
|
+
|
|
516
|
+
**`ts-handwired-http`** / **`ts-handwired-model`** (TypeScript, `.ts`/`.tsx`/`.mts`,
|
|
517
|
+
[bmsuisse/devtools#52](https://github.com/bmsuisse/devtools/issues/52)) — a frontend
|
|
518
|
+
package that already generates an API client from the backend's OpenAPI schema
|
|
519
|
+
(its nearest `package.json` depends on `openapi-typescript`/`openapi-fetch`,
|
|
520
|
+
`@hey-api/openapi-ts`, `orval`, ... or has an `openapi` script) should use it. The
|
|
521
|
+
rule flags hand-wired `fetch(...)`, `axios`/`axios.create(...)` and
|
|
522
|
+
`new XMLHttpRequest()` (`ts-handwired-http`), and a `.json()` result typed by
|
|
523
|
+
casting/annotating a hand-written model (`ts-handwired-model`, only reported
|
|
524
|
+
outside the block of a `fetch`/`axios` call, which the http rule already covers). Packages without a generator are skipped, as
|
|
525
|
+
are generated code (`generated/`, `*.gen.ts`, `*.generated.ts`, `api-types.ts`,
|
|
526
|
+
`*.d.ts`; also their `.mts`/`.tsx` variants), tests (`*.test.*`, `*.spec.*`, `__tests__/`, `e2e/`, `tests/`) and
|
|
527
|
+
third-party URLs (`fetch("https://...")`). Hand-wired access is fine for files and
|
|
528
|
+
other non-JSON traffic, since generators handle those badly, so a call is *not*
|
|
529
|
+
flagged when its enclosing block mentions `FormData`, `Blob`/`.blob()`/
|
|
530
|
+
`.arrayBuffer()`, `getReader()`/`response.body`/`TextDecoder`/`EventSource`/`SSE`,
|
|
531
|
+
`createObjectURL`, `new File(`, an `application/octet-stream`/`multipart/`/
|
|
532
|
+
`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).
|
|
533
|
+
It's a tokenizer-level heuristic, not a type-aware analysis; to silence a
|
|
534
|
+
legitimate exception, put a comment on (or right above) the line:
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
// bdt-lint: ignore ts-handwired-http -- websocket handshake, not in the schema
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
**Tooling config** — the repo must declare `ty`, `ruff` and `pytest` as
|
|
541
|
+
dependencies, have `pytest` configured (`[tool.pytest.ini_options]` or a
|
|
542
|
+
`pytest.ini`/`setup.cfg`), and have a `prek.toml` (see the `prek` skill).
|
|
543
|
+
Never a hard block — bypass it for one run with `--no-tooling-check`, or
|
|
544
|
+
permanently for the repo via `pyproject.toml`:
|
|
545
|
+
|
|
546
|
+
```toml
|
|
547
|
+
[tool.bdt.lint]
|
|
548
|
+
skip_tooling_check = true
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Other `[tool.bdt.lint]` knobs (all optional): `exclude_dirs` (extra directory
|
|
552
|
+
names to skip, beyond the built-in `.venv`/`node_modules`/etc. list),
|
|
553
|
+
`pydantic_field_threshold` (default 5), `pydantic_base_classes` (default
|
|
554
|
+
`["BaseModel", "PostgresTableModel"]`), `pydantic_allowed_subdirs` (default
|
|
555
|
+
`["models", "schemas", "dto"]`), `pydantic_api_dir_names` (default `["api"]`),
|
|
556
|
+
`ts_exclude_globs` (repo-relative globs of TypeScript files to skip, e.g.
|
|
557
|
+
`["src/legacy/*"]`), `ts_non_json_markers` (extra strings that mark a call's
|
|
558
|
+
enclosing block as non-JSON traffic).
|
|
559
|
+
|
|
560
|
+
Requires [sqlglot](https://pypi.org/project/sqlglot/) for the SQL checks —
|
|
561
|
+
already pulled in transitively via `pgdevkit[db]`, but declared explicitly as
|
|
562
|
+
the `bmsdna-devtools[lint]` extra; a clear install hint is printed (not a raw
|
|
563
|
+
`ImportError`) if it's ever missing.
|
|
564
|
+
|
|
565
|
+
## `bdt find-injection`
|
|
566
|
+
|
|
567
|
+
Scans backend and frontend code for injection risks, and warns when a web project has no
|
|
568
|
+
Content-Security-Policy.
|
|
569
|
+
|
|
570
|
+
```bash
|
|
571
|
+
bdt find-injection [PATHS...] # files/folders, default: current directory
|
|
572
|
+
bdt find-injection --diff [--base main] # only files changed on this branch + uncommitted/untracked
|
|
573
|
+
bdt find-injection --strict # also fail on "review" items
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
Findings have two severities: **error** (a definite unsafe pattern, exit code 1) and **review**
|
|
577
|
+
(depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
|
|
578
|
+
code 0 unless `--strict`).
|
|
579
|
+
|
|
580
|
+
- **SQL** (Python `.execute()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
581
|
+
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
|
|
582
|
+
a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
|
|
583
|
+
call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
|
|
584
|
+
reports unverified calls.)
|
|
585
|
+
- **Other Python sinks**: `eval`/`exec`, `os.system`, `subprocess(..., shell=True)`, `yaml.load`
|
|
586
|
+
without a safe loader, `pickle.loads`, `Markup()`/`mark_safe()`, Jinja `autoescape=False`,
|
|
587
|
+
`render_template_string`.
|
|
588
|
+
- **Frontend** (TS/JS/JSX/Vue/Svelte/HTML): `innerHTML`/`outerHTML`/`insertAdjacentHTML`,
|
|
589
|
+
`dangerouslySetInnerHTML`, `v-html`, `document.write`, `eval`, `new Function`, string
|
|
590
|
+
`setTimeout`/`setInterval`, `javascript:` URLs, `postMessage(..., "*")`, `srcdoc`, and `<iframe>`
|
|
591
|
+
without `sandbox` (or with `allow-scripts` + `allow-same-origin`).
|
|
592
|
+
- **CSP**: `csp-missing` when a web project (frontend files or a Python web framework) has no
|
|
593
|
+
`Content-Security-Policy` anywhere in the repo (header in code, `<meta http-equiv>`,
|
|
594
|
+
`staticwebapp.config.json`, nginx/web.config, ...); `csp-weakened` for `'unsafe-inline'`,
|
|
595
|
+
`'unsafe-eval'` or a wildcard `script-src`. The CSP lookup always covers the whole repo, even with `--diff`.
|
|
596
|
+
|
|
597
|
+
Test files and generated code are skipped. Silence a confirmed-safe finding with
|
|
598
|
+
`# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
|
|
599
|
+
|
|
458
600
|
## Releasing
|
|
459
601
|
|
|
460
602
|
Bump `version` in `pyproject.toml` as part of your PR, same as any other
|
|
@@ -580,12 +580,20 @@ def update(
|
|
|
580
580
|
description: str | None = None,
|
|
581
581
|
board: str | None = None,
|
|
582
582
|
tags: list[str] | None = None,
|
|
583
|
+
remove_tags: list[str] | None = None,
|
|
583
584
|
state: str | None = None,
|
|
584
585
|
) -> dict | None:
|
|
585
586
|
"""Update a work item's fields. Unlike `create`, `board` is only resolved (and the Area Path only
|
|
586
587
|
touched) when explicitly given — it never falls back to `[tool.bdt.ado].board`, so an unrelated
|
|
587
588
|
field update (e.g. just `--title`) can't silently move the item to a different team's board.
|
|
588
589
|
|
|
590
|
+
`remove_tags` removes just those tags, leaving the rest untouched -- unlike `tags` (which
|
|
591
|
+
replaces the whole `System.Tags` field, mirroring GitHub's `--remove-label` alongside the
|
|
592
|
+
add/replace `--tag`/`--label`). If `tags` is also given, `remove_tags` is applied on top of it
|
|
593
|
+
(so a value in both wins as "removed"); otherwise the work item's current tags are fetched
|
|
594
|
+
first as the base to remove from -- see `add_tag` for why there's no single "remove one tag"
|
|
595
|
+
Azure DevOps patch op.
|
|
596
|
+
|
|
589
597
|
If `state` isn't one of this work item's type's valid states (state names, and which ones are
|
|
590
598
|
terminal, are defined per work item type per process template — e.g. a Basic-process Issue has
|
|
591
599
|
'Done' but no 'Closed'), the state is left unchanged and a comment records the exact state that
|
|
@@ -613,11 +621,32 @@ def update(
|
|
|
613
621
|
)
|
|
614
622
|
applied_state = None
|
|
615
623
|
|
|
616
|
-
|
|
624
|
+
has_tag_change = tags is not None or bool(remove_tags)
|
|
625
|
+
if title is None and description is None and area_path is None and not has_tag_change and applied_state is None:
|
|
617
626
|
if state is not None:
|
|
618
627
|
print(f"Work item #{work_item_id}: '{state}' isn't a valid state here — noted in a comment.")
|
|
619
628
|
return None
|
|
620
|
-
sys.exit("Nothing to update — provide at least one of --title, --description, --board, --tag, --state.")
|
|
629
|
+
sys.exit("Nothing to update — provide at least one of --title, --description, --board, --tag, --remove-tag, --state.")
|
|
630
|
+
|
|
631
|
+
if remove_tags:
|
|
632
|
+
removal_set = {t.casefold() for t in remove_tags}
|
|
633
|
+
if tags is not None:
|
|
634
|
+
# An explicit `tags` (full replace) always writes, same as plain --tag on its own --
|
|
635
|
+
# `remove_tags` here is just a filter on top of the caller-given replacement list.
|
|
636
|
+
tags = [t for t in tags if t.casefold() not in removal_set]
|
|
637
|
+
else:
|
|
638
|
+
current_tags = get_work_item_tags(session, remote, work_item_id)
|
|
639
|
+
filtered_tags = [t for t in current_tags if t.casefold() not in removal_set]
|
|
640
|
+
if filtered_tags != current_tags:
|
|
641
|
+
tags = filtered_tags
|
|
642
|
+
elif title is None and description is None and area_path is None and applied_state is None:
|
|
643
|
+
# None of `remove_tags` were actually present, and nothing else was given either --
|
|
644
|
+
# mirrors `add_tag`'s own idempotency check (same file) so this doesn't needlessly
|
|
645
|
+
# bump the work item's revision/history with a same-value System.Tags PATCH.
|
|
646
|
+
print(f"Work item #{work_item_id}: none of the given --remove-tag value(s) were present — nothing to do.")
|
|
647
|
+
return None
|
|
648
|
+
# else: leave `tags` as None -- other given fields still get their own ops below,
|
|
649
|
+
# without forcing a redundant, unchanged System.Tags op alongside them.
|
|
621
650
|
|
|
622
651
|
if description is not None:
|
|
623
652
|
description = ensure_agent_session_note(description, also_check=title)
|