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.
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/PKG-INFO +117 -8
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/README.md +114 -7
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/ado_issue.py +98 -23
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/cli.py +205 -33
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/gh_issue.py +51 -3
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/gh_pr.py +50 -21
- bmsdna_devtools-0.25.0/bmsdna/devtools/lint.py +183 -0
- bmsdna_devtools-0.25.0/bmsdna/devtools/lint_findings.py +29 -0
- bmsdna_devtools-0.25.0/bmsdna/devtools/lint_models.py +104 -0
- bmsdna_devtools-0.25.0/bmsdna/devtools/lint_sql.py +363 -0
- bmsdna_devtools-0.25.0/bmsdna/devtools/lint_tooling.py +86 -0
- bmsdna_devtools-0.25.0/bmsdna/devtools/lint_typescript.py +394 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/pr_build.py +62 -12
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/pyproject.toml +9 -1
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_ado_issue.py +78 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_ado_issue_flow.py +54 -0
- bmsdna_devtools-0.25.0/tests/test_cli_issue.py +335 -0
- bmsdna_devtools-0.25.0/tests/test_cli_pr_id.py +234 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_gh_issue.py +78 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_gh_pr.py +155 -3
- bmsdna_devtools-0.25.0/tests/test_lint.py +97 -0
- bmsdna_devtools-0.25.0/tests/test_lint_models.py +125 -0
- bmsdna_devtools-0.25.0/tests/test_lint_sql.py +225 -0
- bmsdna_devtools-0.25.0/tests/test_lint_tooling.py +83 -0
- bmsdna_devtools-0.25.0/tests/test_lint_typescript.py +286 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_pr_build.py +34 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_pr_build_flow.py +61 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/uv.lock +8 -1
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/.gitignore +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/.python-version +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/justfile +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.25.0}/tests/test_testdb.py +0 -0
- {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.
|
|
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,
|
|
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,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,
|
|
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,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"
|
|
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
|
-
|
|
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)
|
|
421
|
-
scoped to a board's Area Path subtree, most recently changed
|
|
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
|
-
|
|
447
|
-
|
|
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"{
|
|
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
|
|
462
|
-
returns ids (not field values), and this batch endpoint
|
|
463
|
-
in the order they were requested, so the caller's WIQL
|
|
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"{
|
|
469
|
-
params={"ids": ",".join(map(str, ids)), "fields":
|
|
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
|
-
|
|
492
|
-
|
|
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
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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)
|