bmsdna-devtools 0.23.0__tar.gz → 0.25.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 (70) hide show
  1. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/PKG-INFO +117 -8
  2. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/README.md +114 -7
  3. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/ado_issue.py +98 -23
  4. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/cli.py +205 -33
  5. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/gh_issue.py +51 -3
  6. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/gh_pr.py +50 -21
  7. bmsdna_devtools-0.25.0/bmsdna/devtools/lint.py +183 -0
  8. bmsdna_devtools-0.25.0/bmsdna/devtools/lint_findings.py +29 -0
  9. bmsdna_devtools-0.25.0/bmsdna/devtools/lint_models.py +104 -0
  10. bmsdna_devtools-0.25.0/bmsdna/devtools/lint_sql.py +363 -0
  11. bmsdna_devtools-0.25.0/bmsdna/devtools/lint_tooling.py +86 -0
  12. bmsdna_devtools-0.25.0/bmsdna/devtools/lint_typescript.py +394 -0
  13. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/pr_build.py +62 -12
  14. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/pyproject.toml +9 -1
  15. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_ado_issue.py +78 -0
  16. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_ado_issue_flow.py +54 -0
  17. bmsdna_devtools-0.25.0/tests/test_cli_issue.py +335 -0
  18. bmsdna_devtools-0.25.0/tests/test_cli_pr_id.py +234 -0
  19. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_gh_issue.py +78 -0
  20. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_gh_pr.py +155 -3
  21. bmsdna_devtools-0.25.0/tests/test_lint.py +97 -0
  22. bmsdna_devtools-0.25.0/tests/test_lint_models.py +125 -0
  23. bmsdna_devtools-0.25.0/tests/test_lint_sql.py +225 -0
  24. bmsdna_devtools-0.25.0/tests/test_lint_tooling.py +83 -0
  25. bmsdna_devtools-0.25.0/tests/test_lint_typescript.py +286 -0
  26. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_pr_build.py +34 -0
  27. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_pr_build_flow.py +61 -0
  28. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/uv.lock +8 -1
  29. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/.github/workflows/auto-release.yml +0 -0
  30. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/.github/workflows/python-publish.yml +0 -0
  31. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/.github/workflows/python-test.yml +0 -0
  32. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/.gitignore +0 -0
  33. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/.python-version +0 -0
  34. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/__init__.py +0 -0
  35. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/ado_auth.py +0 -0
  36. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/app_service_logs.py +0 -0
  37. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/bdt_config.py +0 -0
  38. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/cli_tools.py +0 -0
  39. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/commit.py +0 -0
  40. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/env_config.py +0 -0
  41. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/find_repo.py +0 -0
  42. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/gitrepo.py +0 -0
  43. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/logs.py +0 -0
  44. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  45. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/pr_labels.py +0 -0
  46. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/pr_markdown.py +0 -0
  47. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/pull.py +0 -0
  48. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/testdb.py +0 -0
  49. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/worktree.py +0 -0
  50. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/justfile +0 -0
  51. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  52. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/conftest.py +0 -0
  53. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_ado_auth.py +0 -0
  54. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_app_service_logs.py +0 -0
  55. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_bdt_config.py +0 -0
  56. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_cli_cleanup.py +0 -0
  57. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_cli_commit.py +0 -0
  58. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_cli_pr_create.py +0 -0
  59. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_cli_pull.py +0 -0
  60. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_cli_tools.py +0 -0
  61. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_commit.py +0 -0
  62. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_env_config.py +0 -0
  63. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_find_repo.py +0 -0
  64. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_gitrepo.py +0 -0
  65. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_pr_issue_link.py +0 -0
  66. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_pr_labels.py +0 -0
  67. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_pr_markdown.py +0 -0
  68. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_pull.py +0 -0
  69. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_testdb.py +0 -0
  70. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.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.23.0
3
+ Version: 0.25.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,94 @@ 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
+
469
578
  ## Releasing
470
579
 
471
580
  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,94 @@ 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
+
458
565
  ## Releasing
459
566
 
460
567
  Bump `version` in `pyproject.toml` as part of your PR, same as any other
@@ -60,8 +60,12 @@ def resolve_board(board: str | None, start: Path | None = None) -> str | None:
60
60
  return board or load_bdt_table("ado", start).get("board")
61
61
 
62
62
 
63
+ def _org_url(remote: AdoRemote) -> str:
64
+ return f"https://dev.azure.com/{quote(remote.org, safe='')}"
65
+
66
+
63
67
  def _base_url(remote: AdoRemote) -> str:
64
- return f"https://dev.azure.com/{quote(remote.org, safe='')}/{quote(remote.project, safe='')}"
68
+ return f"{_org_url(remote)}/{quote(remote.project, safe='')}"
65
69
 
66
70
 
67
71
  def get_team_area_path(session: requests.Session, remote: AdoRemote, team: str) -> str:
@@ -395,8 +399,11 @@ def html_url(work_item: dict) -> str | None:
395
399
  return work_item.get("_links", {}).get("html", {}).get("href")
396
400
 
397
401
 
398
- def edit_url(remote: AdoRemote, work_item_id: int) -> str:
399
- return f"{_base_url(remote)}/_workitems/edit/{work_item_id}"
402
+ def edit_url(remote: AdoRemote, work_item_id: int, project: str | None = None) -> str:
403
+ """`project` overrides `remote.project` -- needed by an org-wide `search()`, whose results
404
+ can belong to a different project than the one the command was run in.
405
+ """
406
+ return f"{_org_url(remote)}/{quote(project or remote.project, safe='')}/_workitems/edit/{work_item_id}"
400
407
 
401
408
 
402
409
  def _escape_wiql_string(value: str) -> str:
@@ -415,11 +422,13 @@ def build_search_wiql(
415
422
  since: str | None = None,
416
423
  area_path: str | None = None,
417
424
  state: str = "open",
425
+ org_wide: bool = False,
426
+ tags: list[str] | None = None,
418
427
  ) -> str:
419
428
  """WIQL for work items whose Title or Description contains every given keyword (ANDed),
420
- optionally restricted to items changed on/after `since` (an ISO 'YYYY-MM-DD' date) and/or
421
- scoped to a board's Area Path subtree, most recently changed first. `keywords` may be empty,
422
- to list work items without a text filter.
429
+ optionally restricted to items changed on/after `since` (an ISO 'YYYY-MM-DD' date), carrying
430
+ every given tag (ANDed), and/or scoped to a board's Area Path subtree, most recently changed
431
+ first. `keywords` may be empty, to list work items without a text filter.
423
432
 
424
433
  `since` is rendered as a UTC ISO 8601 literal (`'YYYY-MM-DDT00:00:00Z'`) — the one
425
434
  DateTime format WIQL accepts regardless of the querying account's locale/date-pattern
@@ -430,11 +439,21 @@ def build_search_wiql(
430
439
  CMMI use 'Closed' for their Completed-category state, Scrum and Basic use 'Done' — so
431
440
  `_TERMINAL_STATES` is the union across the built-in templates, not a per-project source of
432
441
  truth (a custom process with its own state names won't be filtered correctly).
442
+
443
+ `org_wide` drops the `[System.TeamProject] = @project` clause -- `@project` is only valid
444
+ when the query runs against a project-scoped WIQL endpoint (see `run_wiql`'s `org_wide`),
445
+ and dropping it is what actually lets the query match work items outside the current project.
446
+
447
+ `tags` is a separate filter from `keywords` -- `Contains Words` against Title/Description
448
+ doesn't match `System.Tags` at all, so a tag search (e.g. every work item tagged
449
+ 'ready4implementation' across the whole org) needs its own `[System.Tags] Contains` clause.
433
450
  """
434
451
  clauses = [
435
452
  f"([System.Title] Contains Words '{_escape_wiql_string(k)}' OR [System.Description] Contains Words '{_escape_wiql_string(k)}')"
436
453
  for k in keywords
437
454
  ]
455
+ for tag in tags or []:
456
+ clauses.append(f"[System.Tags] Contains '{_escape_wiql_string(tag)}'")
438
457
  if since:
439
458
  clauses.append(f"[System.ChangedDate] >= '{since}T00:00:00Z'")
440
459
  if area_path:
@@ -443,13 +462,21 @@ def build_search_wiql(
443
462
  clauses.append(" AND ".join(f"[System.State] <> '{s}'" for s in _TERMINAL_STATES))
444
463
  elif state == "closed":
445
464
  clauses.append("(" + " OR ".join(f"[System.State] = '{s}'" for s in _TERMINAL_STATES) + ")")
446
- where = " AND ".join(["[System.TeamProject] = @project", *clauses])
447
- return f"SELECT [System.Id] FROM WorkItems WHERE {where} ORDER BY [System.ChangedDate] DESC"
465
+ if not org_wide:
466
+ clauses.insert(0, "[System.TeamProject] = @project")
467
+ query = "SELECT [System.Id] FROM WorkItems"
468
+ if clauses:
469
+ query += " WHERE " + " AND ".join(clauses)
470
+ return f"{query} ORDER BY [System.ChangedDate] DESC"
448
471
 
449
472
 
450
- def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int) -> list[int]:
473
+ def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int, org_wide: bool = False) -> list[int]:
474
+ """`org_wide` runs the query against the org-level WIQL endpoint (no `/{project}` segment),
475
+ which Azure DevOps resolves across every project in the org instead of just `remote.project`.
476
+ """
477
+ base = _org_url(remote) if org_wide else _base_url(remote)
451
478
  r = session.post(
452
- f"{_base_url(remote)}/_apis/wit/wiql",
479
+ f"{base}/_apis/wit/wiql",
453
480
  params={"api-version": "7.1", "$top": top},
454
481
  json={"query": wiql},
455
482
  )
@@ -457,16 +484,23 @@ def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int)
457
484
  return [wi["id"] for wi in r.json()["workItems"]]
458
485
 
459
486
 
460
- def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int]) -> list[dict]:
461
- """Batch-fetch Title/State for a set of work item ids, in the given `ids` order — WIQL only
462
- returns ids (not field values), and this batch endpoint doesn't guarantee it echoes them back
463
- in the order they were requested, so the caller's WIQL `ORDER BY` isn't preserved otherwise.
487
+ def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int], org_wide: bool = False) -> list[dict]:
488
+ """Batch-fetch Title/State (plus TeamProject when `org_wide`) for a set of work item ids, in
489
+ the given `ids` order — WIQL only returns ids (not field values), and this batch endpoint
490
+ doesn't guarantee it echoes them back in the order they were requested, so the caller's WIQL
491
+ `ORDER BY` isn't preserved otherwise.
492
+
493
+ `org_wide` fetches via the org-level endpoint (ids from an org-wide `run_wiql` may belong to
494
+ a project other than `remote.project`) and also asks for `System.TeamProject`, so `search()`
495
+ can show which project each result came from.
464
496
  """
465
497
  if not ids:
466
498
  return []
499
+ base = _org_url(remote) if org_wide else _base_url(remote)
500
+ fields = "System.Title,System.State,System.TeamProject" if org_wide else "System.Title,System.State"
467
501
  r = session.get(
468
- f"{_base_url(remote)}/_apis/wit/workitems",
469
- params={"ids": ",".join(map(str, ids)), "fields": "System.Title,System.State", "api-version": "7.1"},
502
+ f"{base}/_apis/wit/workitems",
503
+ params={"ids": ",".join(map(str, ids)), "fields": fields, "api-version": "7.1"},
470
504
  )
471
505
  r.raise_for_status()
472
506
  by_id = {item["id"]: item for item in r.json()["value"]}
@@ -481,19 +515,31 @@ def search(
481
515
  board: str | None = None,
482
516
  top: int = 10,
483
517
  state: str = "open",
518
+ org_wide: bool = False,
519
+ tags: list[str] | None = None,
484
520
  ) -> list[dict]:
485
521
  """Search (or, with no keywords, just list) work items by keywords (ANDed, matched against
486
522
  Title or Description) and state, most recently changed first. `board` is an Azure Boards team
487
523
  name (like `create`'s `--board`) — resolved to its Area Path so results are scoped to that
488
- team's subtree instead of the whole project.
524
+ team's subtree instead of the whole project. `tags` is a separate ANDed filter against
525
+ `System.Tags` (see `build_search_wiql`) -- a plain keyword doesn't match tags.
526
+
527
+ `org_wide` searches every project in `remote.org` instead of just `remote.project` --
528
+ mutually exclusive with `board`, since a team's Area Path only means something within a
529
+ single project.
489
530
  """
531
+ if org_wide and board:
532
+ sys.exit("--board can't be combined with --org-wide (a board's Area Path is scoped to a single project).")
490
533
  area_path = get_team_area_path(session, remote, board) if board else None
491
- ids = run_wiql(session, remote, build_search_wiql(keywords, since, area_path, state), top)
492
- items = get_work_items(session, remote, ids)
534
+ wiql = build_search_wiql(keywords, since, area_path, state, org_wide=org_wide, tags=tags)
535
+ ids = run_wiql(session, remote, wiql, top, org_wide=org_wide)
536
+ items = get_work_items(session, remote, ids, org_wide=org_wide)
493
537
  for item in items:
494
538
  fields = item["fields"]
495
- print(f"#{item['id']} [{fields['System.State']}] {fields['System.Title']}")
496
- print(edit_url(remote, item["id"]))
539
+ project = fields.get("System.TeamProject")
540
+ prefix = f"[{project}] " if org_wide else ""
541
+ print(f"{prefix}#{item['id']} [{fields['System.State']}] {fields['System.Title']}")
542
+ print(edit_url(remote, item["id"], project if org_wide else None))
497
543
  if not items:
498
544
  print("No matching work items found.")
499
545
  return items
@@ -534,12 +580,20 @@ def update(
534
580
  description: str | None = None,
535
581
  board: str | None = None,
536
582
  tags: list[str] | None = None,
583
+ remove_tags: list[str] | None = None,
537
584
  state: str | None = None,
538
585
  ) -> dict | None:
539
586
  """Update a work item's fields. Unlike `create`, `board` is only resolved (and the Area Path only
540
587
  touched) when explicitly given — it never falls back to `[tool.bdt.ado].board`, so an unrelated
541
588
  field update (e.g. just `--title`) can't silently move the item to a different team's board.
542
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
+
543
597
  If `state` isn't one of this work item's type's valid states (state names, and which ones are
544
598
  terminal, are defined per work item type per process template — e.g. a Basic-process Issue has
545
599
  'Done' but no 'Closed'), the state is left unchanged and a comment records the exact state that
@@ -567,11 +621,32 @@ def update(
567
621
  )
568
622
  applied_state = None
569
623
 
570
- 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:
571
626
  if state is not None:
572
627
  print(f"Work item #{work_item_id}: '{state}' isn't a valid state here — noted in a comment.")
573
628
  return None
574
- 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.
575
650
 
576
651
  if description is not None:
577
652
  description = ensure_agent_session_note(description, also_check=title)