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.
Files changed (76) hide show
  1. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/PKG-INFO +44 -1
  2. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/README.md +43 -0
  3. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/cli.py +26 -0
  4. bmsdna_devtools-0.29.0/bmsdna/devtools/issue_do.py +71 -0
  5. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/lint_sql.py +48 -9
  6. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/pyproject.toml +1 -1
  7. bmsdna_devtools-0.29.0/tests/test_issue_do.py +20 -0
  8. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_lint_sql.py +73 -0
  9. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/uv.lock +1 -1
  10. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/.github/workflows/auto-release.yml +0 -0
  11. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/.github/workflows/python-publish.yml +0 -0
  12. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/.github/workflows/python-test.yml +0 -0
  13. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/.gitignore +0 -0
  14. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/.python-version +0 -0
  15. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/__init__.py +0 -0
  16. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/ado_auth.py +0 -0
  17. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/ado_issue.py +0 -0
  18. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/app_service_logs.py +0 -0
  19. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/bdt_config.py +0 -0
  20. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/cli_tools.py +0 -0
  21. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/commit.py +0 -0
  22. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/env_config.py +0 -0
  23. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/find_injection.py +0 -0
  24. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/find_repo.py +0 -0
  25. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/gh_issue.py +0 -0
  26. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/gh_pr.py +0 -0
  27. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/gitrepo.py +0 -0
  28. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/injection_frontend.py +0 -0
  29. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/injection_python.py +0 -0
  30. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/lint.py +0 -0
  31. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/lint_findings.py +0 -0
  32. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/lint_models.py +0 -0
  33. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/lint_tooling.py +0 -0
  34. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/lint_typescript.py +0 -0
  35. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/logs.py +0 -0
  36. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/pr_build.py +0 -0
  37. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  38. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/pr_labels.py +0 -0
  39. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/pr_markdown.py +0 -0
  40. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/pull.py +0 -0
  41. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/testdb.py +0 -0
  42. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/bmsdna/devtools/worktree.py +0 -0
  43. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/justfile +0 -0
  44. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  45. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/conftest.py +0 -0
  46. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_ado_auth.py +0 -0
  47. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_ado_issue.py +0 -0
  48. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_ado_issue_flow.py +0 -0
  49. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_app_service_logs.py +0 -0
  50. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_bdt_config.py +0 -0
  51. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_cleanup.py +0 -0
  52. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_commit.py +0 -0
  53. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_issue.py +0 -0
  54. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_pr_create.py +0 -0
  55. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_pr_id.py +0 -0
  56. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_pull.py +0 -0
  57. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_cli_tools.py +0 -0
  58. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_commit.py +0 -0
  59. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_env_config.py +0 -0
  60. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_find_injection.py +0 -0
  61. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_find_repo.py +0 -0
  62. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_gh_issue.py +0 -0
  63. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_gh_pr.py +0 -0
  64. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_gitrepo.py +0 -0
  65. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_lint.py +0 -0
  66. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_lint_models.py +0 -0
  67. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_lint_tooling.py +0 -0
  68. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_lint_typescript.py +0 -0
  69. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_pr_build.py +0 -0
  70. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_pr_build_flow.py +0 -0
  71. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_pr_issue_link.py +0 -0
  72. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_pr_labels.py +0 -0
  73. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_pr_markdown.py +0 -0
  74. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_pull.py +0 -0
  75. {bmsdna_devtools-0.28.0 → bmsdna_devtools-0.29.0}/tests/test_testdb.py +0 -0
  76. {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.28.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 _literal_findings(text: str, path: Path, lineno: int) -> list[Finding]:
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
- is_complex = (
291
- line_count > 4
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
- self.findings.extend(_check_query_arg(query_arg, self._lookup, self.path, node.lineno, self.trust, self.review))
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.28.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
@@ -22,7 +22,7 @@ wheels = [
22
22
 
23
23
  [[package]]
24
24
  name = "bmsdna-devtools"
25
- version = "0.28.0"
25
+ version = "0.29.0"
26
26
  source = { editable = "." }
27
27
  dependencies = [
28
28
  { name = "pgdevkit", extra = ["db"] },