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.
Files changed (74) hide show
  1. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/PKG-INFO +152 -8
  2. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/README.md +149 -7
  3. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/ado_issue.py +31 -2
  4. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/cli.py +215 -36
  5. bmsdna_devtools-0.26.0/bmsdna/devtools/find_injection.py +310 -0
  6. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/gh_pr.py +50 -21
  7. bmsdna_devtools-0.26.0/bmsdna/devtools/injection_frontend.py +237 -0
  8. bmsdna_devtools-0.26.0/bmsdna/devtools/injection_python.py +189 -0
  9. bmsdna_devtools-0.26.0/bmsdna/devtools/lint.py +183 -0
  10. bmsdna_devtools-0.26.0/bmsdna/devtools/lint_findings.py +30 -0
  11. bmsdna_devtools-0.26.0/bmsdna/devtools/lint_models.py +104 -0
  12. bmsdna_devtools-0.26.0/bmsdna/devtools/lint_sql.py +500 -0
  13. bmsdna_devtools-0.26.0/bmsdna/devtools/lint_tooling.py +86 -0
  14. bmsdna_devtools-0.26.0/bmsdna/devtools/lint_typescript.py +394 -0
  15. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_build.py +62 -12
  16. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/pyproject.toml +9 -1
  17. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_ado_issue_flow.py +54 -0
  18. bmsdna_devtools-0.26.0/tests/test_cli_issue.py +335 -0
  19. bmsdna_devtools-0.26.0/tests/test_cli_pr_id.py +234 -0
  20. bmsdna_devtools-0.26.0/tests/test_find_injection.py +238 -0
  21. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_gh_pr.py +155 -3
  22. bmsdna_devtools-0.26.0/tests/test_lint.py +97 -0
  23. bmsdna_devtools-0.26.0/tests/test_lint_models.py +125 -0
  24. bmsdna_devtools-0.26.0/tests/test_lint_sql.py +296 -0
  25. bmsdna_devtools-0.26.0/tests/test_lint_tooling.py +83 -0
  26. bmsdna_devtools-0.26.0/tests/test_lint_typescript.py +286 -0
  27. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_pr_build.py +34 -0
  28. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_pr_build_flow.py +61 -0
  29. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/uv.lock +8 -1
  30. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/.github/workflows/auto-release.yml +0 -0
  31. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/.github/workflows/python-publish.yml +0 -0
  32. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/.github/workflows/python-test.yml +0 -0
  33. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/.gitignore +0 -0
  34. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/.python-version +0 -0
  35. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/__init__.py +0 -0
  36. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/ado_auth.py +0 -0
  37. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/app_service_logs.py +0 -0
  38. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/bdt_config.py +0 -0
  39. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/cli_tools.py +0 -0
  40. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/commit.py +0 -0
  41. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/env_config.py +0 -0
  42. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/find_repo.py +0 -0
  43. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/gh_issue.py +0 -0
  44. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/gitrepo.py +0 -0
  45. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/logs.py +0 -0
  46. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  47. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_labels.py +0 -0
  48. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_markdown.py +0 -0
  49. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pull.py +0 -0
  50. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/testdb.py +0 -0
  51. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/worktree.py +0 -0
  52. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/justfile +0 -0
  53. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  54. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/conftest.py +0 -0
  55. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_ado_auth.py +0 -0
  56. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_ado_issue.py +0 -0
  57. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_app_service_logs.py +0 -0
  58. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_bdt_config.py +0 -0
  59. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_cli_cleanup.py +0 -0
  60. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_cli_commit.py +0 -0
  61. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_cli_pr_create.py +0 -0
  62. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_cli_pull.py +0 -0
  63. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_cli_tools.py +0 -0
  64. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_commit.py +0 -0
  65. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_env_config.py +0 -0
  66. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_find_repo.py +0 -0
  67. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_gh_issue.py +0 -0
  68. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_gitrepo.py +0 -0
  69. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_pr_issue_link.py +0 -0
  70. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_pr_labels.py +0 -0
  71. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_pr_markdown.py +0 -0
  72. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_pull.py +0 -0
  73. {bmsdna_devtools-0.24.0 → bmsdna_devtools-0.26.0}/tests/test_testdb.py +0 -0
  74. {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.24.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, and Azure log queries.
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` sets/replaces the full tag list (repeatable; omit on
221
- `update` to leave tags unchanged). `--state` (`update` only) sets
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` adds a label on `create`, or adds/removes one on
246
- `update` (paired with `--remove-label`); labels must already exist in the
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, and Azure log queries.
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` sets/replaces the full tag list (repeatable; omit on
210
- `update` to leave tags unchanged). `--state` (`update` only) sets
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` adds a label on `create`, or adds/removes one on
235
- `update` (paired with `--remove-label`); labels must already exist in the
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
- if title is None and description is None and area_path is None and tags is None and applied_state is None:
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)