bmsdna-devtools 0.28.0__tar.gz → 0.29.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.28.0 → bmsdna_devtools-0.29.0}/PKG-INFO +44 -1
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/README.md +43 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/cli.py +26 -0
- bmsdna_devtools-0.29.0/bmsdna/devtools/issue_do.py +71 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/lint_sql.py +48 -9
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/pyproject.toml +1 -1
- bmsdna_devtools-0.29.0/tests/test_issue_do.py +20 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_lint_sql.py +73 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/uv.lock +1 -1
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/.gitignore +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/.python-version +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/ado_issue.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/find_injection.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/justfile +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_find_injection.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_worktree_clean.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bmsdna-devtools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.29.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
|
|
@@ -287,6 +287,17 @@ CLI flag beats `[tool.bdt.github].board` in `pyproject.toml`:
|
|
|
287
287
|
board = "Roadmap"
|
|
288
288
|
```
|
|
289
289
|
|
|
290
|
+
## `bdt issue do`
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
bdt issue do 60 [--agent claude] [--dry-run] [extra agent args...]
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Hands an issue (GitHub) or work item (Azure DevOps) to a coding agent. Fetches the title and
|
|
297
|
+
description and runs `claude -p "<prompt>" --name "60: <title>"`, so the session is easy to find
|
|
298
|
+
with `claude --resume`. `--agent` picks another executable (it then gets just the prompt as its
|
|
299
|
+
last argument); extra args are passed through; `--dry-run` prints the command.
|
|
300
|
+
|
|
290
301
|
## `bdt worktree`
|
|
291
302
|
|
|
292
303
|
```bash
|
|
@@ -297,6 +308,34 @@ Creates `.worktrees/<name>` branched from `--base`, initializes submodules
|
|
|
297
308
|
(unless `--no-submodules`), and copies an env file into the new worktree as
|
|
298
309
|
`.env` (auto-detects `.local_env` then `.env` if `--env-file` isn't given).
|
|
299
310
|
|
|
311
|
+
## `bdt find-repo`
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
bdt find-repo my-repo [--root ~/projects] [--org MYORG] [--github-org MYORG] [--yes] [--pat PAT]
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Finds a repo by name: first among the local clones under the work dir, then -- only if there is
|
|
318
|
+
no local match -- in an Azure DevOps org and/or a GitHub org. Names match exactly
|
|
319
|
+
(case-insensitive) if any repo has that name, otherwise by substring. Local matches are printed
|
|
320
|
+
as paths. A single remote-only match is offered for cloning (`--yes` skips the prompt; without a
|
|
321
|
+
TTY it never prompts and just tells you to pass `--yes`); several matches are listed and nothing
|
|
322
|
+
is cloned.
|
|
323
|
+
|
|
324
|
+
Clone destinations: `<root>/<project>/<repo>` for Azure DevOps, `<root>/github/<repo>` for GitHub.
|
|
325
|
+
|
|
326
|
+
Configuration (flags override env vars):
|
|
327
|
+
|
|
328
|
+
| Setting | Flag | Env vars | Default |
|
|
329
|
+
| --- | --- | --- | --- |
|
|
330
|
+
| Local work dir | `--root` | `AZDO_WORK_DIR`, `BMS_WORK_DIR` | `~/projects` (`C:/Projects` on Windows) |
|
|
331
|
+
| Azure DevOps org | `--org` | `AZDO_ORG`, `BMS_ORG` | none (ADO not searched) |
|
|
332
|
+
| GitHub org | `--github-org` | `GITHUB_ORG`, `BMS_GITHUB_ORG` | none (GitHub not searched) |
|
|
333
|
+
| ADO PAT for cloning | `--pat` | `AZURE_DEVOPS_EXT_PAT`, `AZURE_DEVOPS_PAT` | falls back to `az` login |
|
|
334
|
+
|
|
335
|
+
Set the org env vars in your shell profile to make `bdt find-repo <name>` work anywhere. It replaces
|
|
336
|
+
the `cross-repo-discovery` skill's `ALL_REPOS.md` sync. Needs `az` (ADO) / `gh` (GitHub) only when
|
|
337
|
+
those orgs are searched.
|
|
338
|
+
|
|
300
339
|
## `bdt cleanup worktrees` / `bdt cleanup orphaned-dbs` / `bdt cleanup db` / `bdt cleanup worktree`
|
|
301
340
|
|
|
302
341
|
```bash
|
|
@@ -509,6 +548,10 @@ it can't see through):
|
|
|
509
548
|
|
|
510
549
|
- **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
|
|
511
550
|
JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
|
|
551
|
+
Simple `INSERT`/`UPDATE`/`DELETE` are exempt from the line limit; `INSERT ... SELECT`,
|
|
552
|
+
`UPDATE ... FROM` and `DELETE ... USING` still count as complex.
|
|
553
|
+
Also applied to any `*_sql`/`*_SQL` variable assigned a literal, even if it never reaches an
|
|
554
|
+
`.execute()` call in the same file (e.g. it's handed to a helper).
|
|
512
555
|
- **`sql-fstring-injection`** / **`sql-concat-injection`** /
|
|
513
556
|
**`sql-percent-format-injection`** / **`sql-format-injection`** — SQL built
|
|
514
557
|
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
@@ -274,6 +274,17 @@ CLI flag beats `[tool.bdt.github].board` in `pyproject.toml`:
|
|
|
274
274
|
board = "Roadmap"
|
|
275
275
|
```
|
|
276
276
|
|
|
277
|
+
## `bdt issue do`
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
bdt issue do 60 [--agent claude] [--dry-run] [extra agent args...]
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Hands an issue (GitHub) or work item (Azure DevOps) to a coding agent. Fetches the title and
|
|
284
|
+
description and runs `claude -p "<prompt>" --name "60: <title>"`, so the session is easy to find
|
|
285
|
+
with `claude --resume`. `--agent` picks another executable (it then gets just the prompt as its
|
|
286
|
+
last argument); extra args are passed through; `--dry-run` prints the command.
|
|
287
|
+
|
|
277
288
|
## `bdt worktree`
|
|
278
289
|
|
|
279
290
|
```bash
|
|
@@ -284,6 +295,34 @@ Creates `.worktrees/<name>` branched from `--base`, initializes submodules
|
|
|
284
295
|
(unless `--no-submodules`), and copies an env file into the new worktree as
|
|
285
296
|
`.env` (auto-detects `.local_env` then `.env` if `--env-file` isn't given).
|
|
286
297
|
|
|
298
|
+
## `bdt find-repo`
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
bdt find-repo my-repo [--root ~/projects] [--org MYORG] [--github-org MYORG] [--yes] [--pat PAT]
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Finds a repo by name: first among the local clones under the work dir, then -- only if there is
|
|
305
|
+
no local match -- in an Azure DevOps org and/or a GitHub org. Names match exactly
|
|
306
|
+
(case-insensitive) if any repo has that name, otherwise by substring. Local matches are printed
|
|
307
|
+
as paths. A single remote-only match is offered for cloning (`--yes` skips the prompt; without a
|
|
308
|
+
TTY it never prompts and just tells you to pass `--yes`); several matches are listed and nothing
|
|
309
|
+
is cloned.
|
|
310
|
+
|
|
311
|
+
Clone destinations: `<root>/<project>/<repo>` for Azure DevOps, `<root>/github/<repo>` for GitHub.
|
|
312
|
+
|
|
313
|
+
Configuration (flags override env vars):
|
|
314
|
+
|
|
315
|
+
| Setting | Flag | Env vars | Default |
|
|
316
|
+
| --- | --- | --- | --- |
|
|
317
|
+
| Local work dir | `--root` | `AZDO_WORK_DIR`, `BMS_WORK_DIR` | `~/projects` (`C:/Projects` on Windows) |
|
|
318
|
+
| Azure DevOps org | `--org` | `AZDO_ORG`, `BMS_ORG` | none (ADO not searched) |
|
|
319
|
+
| GitHub org | `--github-org` | `GITHUB_ORG`, `BMS_GITHUB_ORG` | none (GitHub not searched) |
|
|
320
|
+
| ADO PAT for cloning | `--pat` | `AZURE_DEVOPS_EXT_PAT`, `AZURE_DEVOPS_PAT` | falls back to `az` login |
|
|
321
|
+
|
|
322
|
+
Set the org env vars in your shell profile to make `bdt find-repo <name>` work anywhere. It replaces
|
|
323
|
+
the `cross-repo-discovery` skill's `ALL_REPOS.md` sync. Needs `az` (ADO) / `gh` (GitHub) only when
|
|
324
|
+
those orgs are searched.
|
|
325
|
+
|
|
287
326
|
## `bdt cleanup worktrees` / `bdt cleanup orphaned-dbs` / `bdt cleanup db` / `bdt cleanup worktree`
|
|
288
327
|
|
|
289
328
|
```bash
|
|
@@ -496,6 +535,10 @@ it can't see through):
|
|
|
496
535
|
|
|
497
536
|
- **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
|
|
498
537
|
JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
|
|
538
|
+
Simple `INSERT`/`UPDATE`/`DELETE` are exempt from the line limit; `INSERT ... SELECT`,
|
|
539
|
+
`UPDATE ... FROM` and `DELETE ... USING` still count as complex.
|
|
540
|
+
Also applied to any `*_sql`/`*_SQL` variable assigned a literal, even if it never reaches an
|
|
541
|
+
`.execute()` call in the same file (e.g. it's handed to a helper).
|
|
499
542
|
- **`sql-fstring-injection`** / **`sql-concat-injection`** /
|
|
500
543
|
**`sql-percent-format-injection`** / **`sql-format-injection`** — SQL built
|
|
501
544
|
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
@@ -15,6 +15,7 @@ from pgdevkit.testdb import constants as pgdevkit_constants
|
|
|
15
15
|
from . import ado_issue, app_service_logs, commit as commit_mod
|
|
16
16
|
from . import env_config
|
|
17
17
|
from . import find_repo as find_repo_mod
|
|
18
|
+
from . import issue_do as issue_do_mod
|
|
18
19
|
from . import gh_issue, gh_pr
|
|
19
20
|
from . import find_injection as find_injection_mod
|
|
20
21
|
from . import lint as lint_mod
|
|
@@ -694,6 +695,31 @@ def issue_search(
|
|
|
694
695
|
)
|
|
695
696
|
|
|
696
697
|
|
|
698
|
+
@issue_app.command("do", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
|
699
|
+
def issue_do(
|
|
700
|
+
ctx: typer.Context,
|
|
701
|
+
number: int = typer.Argument(..., help="Issue number (GitHub) or work item ID (Azure DevOps)"),
|
|
702
|
+
agent: str = typer.Option("claude", "--agent", help="Agent executable to start (claude runs headless: `claude -p ... --name '<number>: <title>'`)"),
|
|
703
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the agent command instead of running it"),
|
|
704
|
+
pat: str | None = typer.Option(
|
|
705
|
+
None,
|
|
706
|
+
"--pat",
|
|
707
|
+
envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"],
|
|
708
|
+
help="Azure DevOps PAT (else falls back to `az` login)",
|
|
709
|
+
),
|
|
710
|
+
) -> None:
|
|
711
|
+
"""Start a coding agent on an issue / work item, with the session named '<number>: <title>'.
|
|
712
|
+
Extra args after the number are passed through to the agent."""
|
|
713
|
+
remote = current_remote()
|
|
714
|
+
if isinstance(remote, GitHubRemote):
|
|
715
|
+
title, body = issue_do_mod.fetch_github(require_gh(), number)
|
|
716
|
+
else:
|
|
717
|
+
session = requests.Session()
|
|
718
|
+
session.headers.update(auth_header(pat))
|
|
719
|
+
title, body = issue_do_mod.fetch_ado(session, remote, number)
|
|
720
|
+
issue_do_mod.run(number, title, body, agent=agent, extra=list(ctx.args), dry_run=dry_run)
|
|
721
|
+
|
|
722
|
+
|
|
697
723
|
@issue_app.command("update")
|
|
698
724
|
def issue_update(
|
|
699
725
|
number: int = typer.Argument(..., help="Issue number (GitHub) or work item ID (Azure DevOps)"),
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""`bdt issue do <number>`: hand an issue / work item to a coding agent.
|
|
2
|
+
|
|
3
|
+
Fetches the issue's title and description (GitHub via `gh`, Azure DevOps via the REST API)
|
|
4
|
+
and starts the agent headless (`claude -p`), naming the session `<number>: <title>` so it is
|
|
5
|
+
easy to find again with `claude --resume`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from .ado_issue import _base_url
|
|
19
|
+
from .gitrepo import AdoRemote
|
|
20
|
+
|
|
21
|
+
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fetch_github(gh: str, number: int) -> tuple[str, str]:
|
|
25
|
+
result = subprocess.run([gh, "issue", "view", str(number), "--json", "title,body"], capture_output=True, text=True, check=False)
|
|
26
|
+
if result.returncode != 0:
|
|
27
|
+
sys.exit(f"gh issue view {number} failed:\n{result.stderr.strip()}")
|
|
28
|
+
data = json.loads(result.stdout)
|
|
29
|
+
return data["title"], data.get("body") or ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fetch_ado(session: requests.Session, remote: AdoRemote, number: int) -> tuple[str, str]:
|
|
33
|
+
r = session.get(
|
|
34
|
+
f"{_base_url(remote)}/_apis/wit/workitems/{number}",
|
|
35
|
+
params={"fields": "System.Title,System.Description", "api-version": "7.1"},
|
|
36
|
+
)
|
|
37
|
+
r.raise_for_status()
|
|
38
|
+
fields = r.json()["fields"]
|
|
39
|
+
# ADO descriptions are HTML; the agent only needs the text.
|
|
40
|
+
description = _HTML_TAG_RE.sub("", (fields.get("System.Description") or "").replace("<br>", "\n").replace("</div>", "\n"))
|
|
41
|
+
return fields["System.Title"], description.strip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def session_name(number: int, title: str) -> str:
|
|
45
|
+
return f"{number}: {title}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_prompt(number: int, title: str, body: str) -> str:
|
|
49
|
+
return (
|
|
50
|
+
f"Work on issue {number}: {title}\n\n{body.strip() or '(no description)'}\n\n"
|
|
51
|
+
"Follow the dev-workflow skill: worktree, draft PR early, implement, test, publish the PR "
|
|
52
|
+
f"and reference issue {number} in it. Do not merge."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_command(agent: str, number: int, title: str, body: str, extra: list[str]) -> list[str]:
|
|
57
|
+
prompt = build_prompt(number, title, body)
|
|
58
|
+
if agent == "claude":
|
|
59
|
+
return [agent, "-p", prompt, "--name", session_name(number, title), *extra]
|
|
60
|
+
# Other agents: no known naming flag, so just hand over the prompt.
|
|
61
|
+
return [agent, *extra, prompt]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run(number: int, title: str, body: str, *, agent: str, extra: list[str], dry_run: bool) -> None:
|
|
65
|
+
cmd = build_command(agent, number, title, body, extra)
|
|
66
|
+
if dry_run:
|
|
67
|
+
print(" ".join(json.dumps(c) if " " in c or "\n" in c else c for c in cmd))
|
|
68
|
+
return
|
|
69
|
+
if shutil.which(agent) is None:
|
|
70
|
+
sys.exit(f"'{agent}' is required for this command but wasn't found on PATH.")
|
|
71
|
+
raise SystemExit(subprocess.run(cmd, check=False).returncode)
|
|
@@ -280,20 +280,26 @@ def _injection_finding(text: str, path: Path, lineno: int, rule: str, how: str)
|
|
|
280
280
|
return [Finding(path, lineno, rule, f"SQL built with {how} -- injection risk. {fix}")]
|
|
281
281
|
|
|
282
282
|
|
|
283
|
-
def
|
|
284
|
-
parsed = parse_sql_text(text)
|
|
285
|
-
if parsed is None:
|
|
286
|
-
return []
|
|
283
|
+
def _complexity_findings(parsed: "exp.Expression", text: str, path: Path, lineno: int) -> list[Finding]:
|
|
287
284
|
findings: list[Finding] = []
|
|
288
|
-
|
|
289
285
|
line_count = len([line for line in text.splitlines() if line.strip()])
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
or parsed.find(exp.Join) is not None
|
|
286
|
+
has_complex_construct = (
|
|
287
|
+
parsed.find(exp.Join) is not None
|
|
293
288
|
or parsed.find(exp.With) is not None
|
|
294
289
|
or parsed.find(exp.Subquery) is not None
|
|
295
290
|
or parsed.find(exp.AggFunc) is not None
|
|
296
291
|
)
|
|
292
|
+
if isinstance(parsed, (exp.Insert, exp.Update, exp.Delete)):
|
|
293
|
+
# A simple write may span many lines (long column lists) -- only its shape counts:
|
|
294
|
+
# INSERT ... SELECT, UPDATE ... FROM and DELETE ... USING are query logic, so they're complex.
|
|
295
|
+
is_complex = (
|
|
296
|
+
has_complex_construct
|
|
297
|
+
or parsed.find(exp.Select) is not None
|
|
298
|
+
or parsed.find(exp.From) is not None
|
|
299
|
+
or bool(parsed.args.get("using"))
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
is_complex = line_count > 4 or has_complex_construct
|
|
297
303
|
if is_complex:
|
|
298
304
|
findings.append(
|
|
299
305
|
Finding(
|
|
@@ -304,6 +310,14 @@ def _literal_findings(text: str, path: Path, lineno: int) -> list[Finding]:
|
|
|
304
310
|
"-- move it to its own .sql file and load it with load_sql()/SqlLoader.",
|
|
305
311
|
)
|
|
306
312
|
)
|
|
313
|
+
return findings
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _literal_findings(text: str, path: Path, lineno: int) -> list[Finding]:
|
|
317
|
+
parsed = parse_sql_text(text)
|
|
318
|
+
if parsed is None:
|
|
319
|
+
return []
|
|
320
|
+
findings = _complexity_findings(parsed, text, path, lineno)
|
|
307
321
|
|
|
308
322
|
if _POSITIONAL_PARAM_RE.search(text):
|
|
309
323
|
findings.append(
|
|
@@ -430,6 +444,12 @@ def _check_query_arg(
|
|
|
430
444
|
return findings
|
|
431
445
|
|
|
432
446
|
|
|
447
|
+
def _is_sql_named(name: str) -> bool:
|
|
448
|
+
"""`FOO_SQL` / `foo_sql` (or a bare `sql`/`SQL`): a variable whose name says it holds SQL text."""
|
|
449
|
+
lowered = name.lower()
|
|
450
|
+
return lowered == "sql" or lowered.endswith("_sql")
|
|
451
|
+
|
|
452
|
+
|
|
433
453
|
def _execute_query_arg(call: ast.Call) -> ast.expr | None:
|
|
434
454
|
if not (isinstance(call.func, ast.Attribute) and call.func.attr in ("execute", "executemany")):
|
|
435
455
|
return None
|
|
@@ -470,12 +490,31 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
|
|
|
470
490
|
def visit_Assign(self, node: ast.Assign) -> None:
|
|
471
491
|
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
|
472
492
|
self._scopes[-1].setdefault(node.targets[0].id, []).append(node.value)
|
|
493
|
+
self._check_sql_named_literal(node.targets[0].id, node.value, node.lineno)
|
|
494
|
+
self.generic_visit(node)
|
|
495
|
+
|
|
496
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
497
|
+
if isinstance(node.target, ast.Name) and node.value is not None:
|
|
498
|
+
self._scopes[-1].setdefault(node.target.id, []).append(node.value)
|
|
499
|
+
self._check_sql_named_literal(node.target.id, node.value, node.lineno)
|
|
473
500
|
self.generic_visit(node)
|
|
474
501
|
|
|
502
|
+
def _check_sql_named_literal(self, name: str, value: ast.expr, lineno: int) -> None:
|
|
503
|
+
"""A `*_sql`/`*_SQL` variable bound to a literal is inline SQL even if it never reaches an
|
|
504
|
+
`.execute()` call in this file (e.g. it's passed to a helper) -- apply the complexity rule."""
|
|
505
|
+
if not (_is_sql_named(name) and isinstance(value, ast.Constant) and isinstance(value.value, str)):
|
|
506
|
+
return
|
|
507
|
+
parsed = parse_sql_text(value.value)
|
|
508
|
+
if parsed is not None:
|
|
509
|
+
self.findings.extend(_complexity_findings(parsed, value.value, self.path, lineno))
|
|
510
|
+
|
|
475
511
|
def visit_Call(self, node: ast.Call) -> None:
|
|
476
512
|
query_arg = _execute_query_arg(node)
|
|
477
513
|
if query_arg is not None:
|
|
478
|
-
|
|
514
|
+
found = _check_query_arg(query_arg, self._lookup, self.path, node.lineno, self.trust, self.review)
|
|
515
|
+
if isinstance(query_arg, ast.Name) and _is_sql_named(query_arg.id):
|
|
516
|
+
found = [f for f in found if f.rule != "sql-inline-too-complex"] # already reported at the assignment
|
|
517
|
+
self.findings.extend(found)
|
|
479
518
|
self.generic_visit(node)
|
|
480
519
|
|
|
481
520
|
|
|
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "bmsdna-devtools"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.29.0"
|
|
14
14
|
description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects"
|
|
15
15
|
readme = "README.md"
|
|
16
16
|
requires-python = ">=3.14" # pgdevkit>=0.7.1 requires 3.14; was >=3.11 before adding it as a dependency
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from bmsdna.devtools import issue_do
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_claude_command_names_session_and_is_headless() -> None:
|
|
5
|
+
cmd = issue_do.build_command("claude", 60, "add command", "body text", ["--model", "opus"])
|
|
6
|
+
assert cmd[:2] == ["claude", "-p"]
|
|
7
|
+
assert "body text" in cmd[2]
|
|
8
|
+
assert cmd[3:5] == ["--name", "60: add command"]
|
|
9
|
+
assert cmd[-2:] == ["--model", "opus"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_other_agent_gets_prompt_last() -> None:
|
|
13
|
+
cmd = issue_do.build_command("codex", 7, "t", "", ["exec"])
|
|
14
|
+
assert cmd[:2] == ["codex", "exec"]
|
|
15
|
+
assert "(no description)" in cmd[2]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_dry_run_prints(capsys) -> None:
|
|
19
|
+
issue_do.run(1, "x", "y", agent="claude", extra=[], dry_run=True)
|
|
20
|
+
assert '"1: x"' in capsys.readouterr().out
|
|
@@ -294,3 +294,76 @@ def c(cur):
|
|
|
294
294
|
path.write_text(source)
|
|
295
295
|
assert sorted(f.rule for f in check_sql_file(path)) == ["sql-fstring-injection"]
|
|
296
296
|
assert sorted(f.rule for f in check_sql_file(path, review=True)) == ["sql-fstring-injection", "sql-unverified-cast"]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_long_simple_insert_update_delete_allowed_inline(tmp_path: Path) -> None:
|
|
300
|
+
source = '''
|
|
301
|
+
async def f(cur):
|
|
302
|
+
await cur.execute(
|
|
303
|
+
"""
|
|
304
|
+
insert into dim.customer (
|
|
305
|
+
id,
|
|
306
|
+
name,
|
|
307
|
+
email,
|
|
308
|
+
active
|
|
309
|
+
) values (%(id)s, %(name)s, %(email)s, true)
|
|
310
|
+
on conflict (id) do update set name = excluded.name
|
|
311
|
+
""",
|
|
312
|
+
{},
|
|
313
|
+
)
|
|
314
|
+
await cur.execute(
|
|
315
|
+
"""
|
|
316
|
+
update dim.customer
|
|
317
|
+
set name = %(name)s,
|
|
318
|
+
email = %(email)s,
|
|
319
|
+
active = false
|
|
320
|
+
where id = %(id)s
|
|
321
|
+
""",
|
|
322
|
+
{},
|
|
323
|
+
)
|
|
324
|
+
await cur.execute(
|
|
325
|
+
"""
|
|
326
|
+
delete from dim.customer
|
|
327
|
+
where id = %(id)s
|
|
328
|
+
and active = false
|
|
329
|
+
and email is null
|
|
330
|
+
""",
|
|
331
|
+
{},
|
|
332
|
+
)
|
|
333
|
+
'''
|
|
334
|
+
assert "sql-inline-too-complex" not in _rules(_findings(source, tmp_path / "a.py"))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_insert_select_and_update_from_still_too_complex(tmp_path: Path) -> None:
|
|
338
|
+
source = '''
|
|
339
|
+
async def f(cur):
|
|
340
|
+
await cur.execute("insert into a (id) select id from b")
|
|
341
|
+
await cur.execute("update a set x = b.x from b where a.id = b.id")
|
|
342
|
+
await cur.execute("delete from a using b where a.id = b.id")
|
|
343
|
+
'''
|
|
344
|
+
findings = _findings(source, tmp_path / "a.py")
|
|
345
|
+
assert [f.rule for f in findings].count("sql-inline-too-complex") == 3
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
_COMPLEX_SELECT = '''"""
|
|
349
|
+
select a.id, p.price
|
|
350
|
+
from core.dim_article a
|
|
351
|
+
left join core.dim_price p on p.article_id = a.id
|
|
352
|
+
where a.active
|
|
353
|
+
"""'''
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_sql_named_variable_with_complex_literal_flagged_without_execute(tmp_path: Path) -> None:
|
|
357
|
+
source = f"ARTICLES_SQL = {_COMPLEX_SELECT}\nother_sql: str = {_COMPLEX_SELECT}\nnot_a_query = {_COMPLEX_SELECT}\n"
|
|
358
|
+
findings = _findings(source, tmp_path / "a.py")
|
|
359
|
+
assert [(f.rule, f.line) for f in findings] == [("sql-inline-too-complex", 1), ("sql-inline-too-complex", 7)]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_sql_named_variable_simple_or_dml_not_flagged(tmp_path: Path) -> None:
|
|
363
|
+
source = 'GET_SQL = "select id from t where id = %(id)s"\nINSERT_SQL = """\ninsert into t (\n a,\n b,\n c,\n d\n) values (1, 2, 3, 4)\n"""\n'
|
|
364
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_sql_named_variable_used_in_execute_reported_once(tmp_path: Path) -> None:
|
|
368
|
+
source = f"ARTICLES_SQL = {_COMPLEX_SELECT}\n\ndef f(cur):\n cur.execute(ARTICLES_SQL)\n"
|
|
369
|
+
assert len(_findings(source, tmp_path / "a.py")) == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|