bmsdna-devtools 0.49.0__tar.gz → 0.50.1__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 (133) hide show
  1. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/PKG-INFO +70 -1
  2. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/README.md +69 -0
  3. bmsdna_devtools-0.50.1/bmsdna/devtools/ci_plan.py +448 -0
  4. bmsdna_devtools-0.50.1/bmsdna/devtools/ci_plan_env.py +243 -0
  5. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/cli.py +22 -0
  6. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/pr_labels.py +19 -4
  7. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/pyproject.toml +1 -1
  8. bmsdna_devtools-0.50.1/tests/test_ci_plan.py +499 -0
  9. bmsdna_devtools-0.50.1/tests/test_ci_plan_env.py +371 -0
  10. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_pr_labels.py +27 -0
  11. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/uv.lock +2 -2
  12. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/.github/workflows/auto-release.yml +0 -0
  13. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/.github/workflows/python-publish.yml +0 -0
  14. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/.github/workflows/python-test.yml +0 -0
  15. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/.gitignore +0 -0
  16. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/.python-version +0 -0
  17. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/__init__.py +0 -0
  18. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/_api_dump.py +0 -0
  19. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/ado_auth.py +0 -0
  20. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/ado_issue.py +0 -0
  21. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/agent_mentions.py +0 -0
  22. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/api_usage.py +0 -0
  23. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/app_service_logs.py +0 -0
  24. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/azure_arm.py +0 -0
  25. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/azure_auth.py +0 -0
  26. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/bdt_config.py +0 -0
  27. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/business_user.py +0 -0
  28. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/cli_tools.py +0 -0
  29. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/commit.py +0 -0
  30. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/dead_code.py +0 -0
  31. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/env_config.py +0 -0
  32. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/env_files.py +0 -0
  33. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/find_injection.py +0 -0
  34. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/find_repo.py +0 -0
  35. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/gh_args.py +0 -0
  36. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/gh_issue.py +0 -0
  37. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/gh_pr.py +0 -0
  38. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/github_api.py +0 -0
  39. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/gitrepo.py +0 -0
  40. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/injection_frontend.py +0 -0
  41. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/injection_python.py +0 -0
  42. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/issue_do.py +0 -0
  43. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/issue_show.py +0 -0
  44. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/issue_watch.py +0 -0
  45. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/lint.py +0 -0
  46. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/lint_findings.py +0 -0
  47. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/lint_models.py +0 -0
  48. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/lint_sql.py +0 -0
  49. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/lint_sql_files.py +0 -0
  50. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/lint_tooling.py +0 -0
  51. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/lint_typescript.py +0 -0
  52. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/logs.py +0 -0
  53. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/pr_build.py +0 -0
  54. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/pr_choice.py +0 -0
  55. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/pr_create_args.py +0 -0
  56. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/pr_info.py +0 -0
  57. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/pr_issue_link.py +0 -0
  58. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/pr_markdown.py +0 -0
  59. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/pr_merge_defaults.py +0 -0
  60. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/pr_ref.py +0 -0
  61. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/pull.py +0 -0
  62. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/testdb.py +0 -0
  63. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/translate.py +0 -0
  64. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/usage/__init__.py +0 -0
  65. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/usage/cli.py +0 -0
  66. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/usage/pipeline.py +0 -0
  67. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/usage/pricing.py +0 -0
  68. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/usage/report.py +0 -0
  69. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/usage/sql/00_schema.sql +0 -0
  70. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/usage/sql/01_ingest_claude.sql +0 -0
  71. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/usage/sql/01_ingest_codex.sql +0 -0
  72. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/usage/sql/02_messages.sql +0 -0
  73. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/usage/sql/03_markers.sql +0 -0
  74. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/usage/sql/04_facts.sql +0 -0
  75. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/usage/sql/05_views.sql +0 -0
  76. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/bmsdna/devtools/worktree.py +0 -0
  77. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/justfile +0 -0
  78. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/skills/bmsdna-devtools/SKILL.md +0 -0
  79. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/conftest.py +0 -0
  80. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/fake_github.py +0 -0
  81. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_ado_auth.py +0 -0
  82. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_ado_issue.py +0 -0
  83. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_ado_issue_flow.py +0 -0
  84. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_agent_mentions.py +0 -0
  85. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_api_usage.py +0 -0
  86. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_app_service_logs.py +0 -0
  87. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_azure_arm.py +0 -0
  88. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_bdt_config.py +0 -0
  89. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_business_user.py +0 -0
  90. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_cli_cleanup.py +0 -0
  91. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_cli_commit.py +0 -0
  92. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_cli_issue.py +0 -0
  93. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_cli_pr_create.py +0 -0
  94. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_cli_pr_id.py +0 -0
  95. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_cli_pull.py +0 -0
  96. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_cli_tools.py +0 -0
  97. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_cli_version.py +0 -0
  98. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_commit.py +0 -0
  99. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_env_config.py +0 -0
  100. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_env_files.py +0 -0
  101. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_find_injection.py +0 -0
  102. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_find_repo.py +0 -0
  103. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_gh_issue.py +0 -0
  104. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_gh_pr.py +0 -0
  105. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_github_api.py +0 -0
  106. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_gitrepo.py +0 -0
  107. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_issue_do.py +0 -0
  108. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_issue_for_me.py +0 -0
  109. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_issue_show.py +0 -0
  110. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_issue_watch.py +0 -0
  111. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_lint.py +0 -0
  112. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_lint_models.py +0 -0
  113. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_lint_sql.py +0 -0
  114. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_lint_sql_files.py +0 -0
  115. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_lint_tooling.py +0 -0
  116. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_lint_typescript.py +0 -0
  117. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_logs.py +0 -0
  118. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_pr_build.py +0 -0
  119. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_pr_build_create_options.py +0 -0
  120. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_pr_build_flow.py +0 -0
  121. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_pr_choice.py +0 -0
  122. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_pr_create_args.py +0 -0
  123. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_pr_description_not_truncated.py +0 -0
  124. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_pr_info.py +0 -0
  125. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_pr_issue_link.py +0 -0
  126. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_pr_markdown.py +0 -0
  127. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_pr_merge_defaults.py +0 -0
  128. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_pull.py +0 -0
  129. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_testdb.py +0 -0
  130. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_translate.py +0 -0
  131. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_usage.py +0 -0
  132. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/tests/test_wait_timeout.py +0 -0
  133. {bmsdna_devtools-0.49.0 → bmsdna_devtools-0.50.1}/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.49.0
3
+ Version: 0.50.1
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: azure-identity>=1.19.0
@@ -868,6 +868,75 @@ bdt logs fetch --env prod
868
868
  bdt logs fetch --env prod --out logs/ --keep-archive
869
869
  ```
870
870
 
871
+ ## `bdt ci plan`
872
+
873
+ Decides which tests a CI run executes, so every pipeline selects tests the same way (the dev -> test -> main flow of
874
+ the `branching-strategy` skill). Add the table to pyproject.toml (an empty one selects the defaults) and pin
875
+ `bmsdna-devtools` as a dev dependency:
876
+
877
+ ```toml
878
+ [tool.bdt.ci.plan]
879
+ on_missing = "infer" # PR without a usable label: infer from the changed files (default) or "fail"
880
+ non_code = ["docs/**", "*.md"] # files no test reads or ships; explicit per repo (full-match globs: `*.md` = root only, `**/*.md` = every folder)
881
+
882
+ [tool.bdt.ci.plan.tiers.unit] # default tiers: unit = tests/unit, e2e = tests/e2e (a tier whose folder is missing is skipped)
883
+ dir = "tests/unit" # areas = sub-folders with tests (test_*.py / *_test.py); `route_planning` <-> `test-route-planning`
884
+
885
+ [tool.bdt.ci.plan.tiers.e2e]
886
+ dir = "tests/e2e" # flat folder: map the test_*.py files to areas instead
887
+ areas = { chat = ["test_chat*.py"], review = ["test_review_*.py"] } # unmatched files form the area `shared`
888
+ ```
889
+
890
+ Labels on the PR: `test-<area>` (one per touched area, selects it in every tier), `test-shared` (just the area
891
+ `shared`), `test-full` (everything), `test-none` (no tests; lint and build still run). Legacy `e2e-<area>` labels count
892
+ as `test-<area>`. With the table present, `bdt pr create` requires one of them (group `test_area`, generated from the
893
+ same folders; a hand-written `test_area` group in `[tool.bdt.pr.required_labels]` wins). Folders without tests (output
894
+ folders like `test-results`, helper packages) are not areas; a changed file in one of them, or directly in the tier
895
+ folder, selects everything because any test may use it.
896
+
897
+ Order of decision, always logged with its reason:
898
+
899
+ 1. Not a PR: the CI run after a push (merge) to `test`/`main` runs nothing (that code was tested on its way into
900
+ `dev`); the nightly, manual runs (also on `test`/`main`), other branches and forks run everything. A manual run of
901
+ `refs/pull/<id>/merge` is planned like that PR (a fork PR still runs everything).
902
+ 2. Branch flow: promotions (`dev` -> `test`, `test` -> `main`, `main` -> `dev`) run nothing. `fix/*` / `hotfix/*` into
903
+ `main` are tested and must be cut from `main` (no unreleased `dev` commits). That check needs full history
904
+ (`fetchDepth: 0` on the checkout); in a shallow checkout or when the refs cannot be fetched it is skipped with a
905
+ warning instead of guessing. Any other PR into `test` / `main` fails the build. Area folders must not be called
906
+ `full` or `none` (they would collide with the labels).
907
+ 3. PR labels decide. No usable label (none, or only unknown `test-*` ones, which are warned about): the changed files
908
+ (merge commit against its first parent) decide. Nothing changed or only `non_code` files -> nothing runs, only area
909
+ tests -> those areas, any other file -> everything. `on_missing = "fail"` fails the build instead.
910
+ 4. Anything unreadable (label API, change list) runs everything: a missing decision never silently skips tests.
911
+
912
+ Outputs (Azure Pipelines variables with `isOutput=true`; plain `name=value` lines elsewhere): `runTests`, and per tier
913
+ (`unit`, `e2e`, ...) `<tier>Run`, `<tier>Areas` and `<tier>Paths`, a shell-quoted argument list for the runner. A
914
+ tier you do not configure and split by area (e.g. flat unit tests) is simply gated on `runTests`.
915
+
916
+ ```yaml
917
+ - stage: Plan
918
+ jobs:
919
+ - job: plan
920
+ steps:
921
+ - script: uv run bdt ci plan
922
+ name: plan # required for isOutput
923
+ env:
924
+ SYSTEM_ACCESSTOKEN: $(System.AccessToken) # reads the PR labels
925
+
926
+ - stage: Test
927
+ dependsOn: Plan
928
+ condition: and(succeeded(), eq(dependencies.Plan.outputs['plan.plan.unitRun'], 'true'))
929
+ variables:
930
+ unitPaths: $[ stageDependencies.Plan.plan.outputs['plan.unitPaths'] ]
931
+ jobs:
932
+ - job: unit
933
+ steps:
934
+ - script: uv run pytest --maxfail=3 $(unitPaths)
935
+ ```
936
+
937
+ Dry run locally: `uv run bdt ci plan --pr-source feat/x --pr-target dev --label test-orders` (or without `--label` to
938
+ see what the changed files select).
939
+
871
940
  ## `bdt lint`
872
941
 
873
942
  Static checks (implementing [bmsuisse/skills#52](https://github.com/bmsuisse/skills/issues/52))
@@ -850,6 +850,75 @@ bdt logs fetch --env prod
850
850
  bdt logs fetch --env prod --out logs/ --keep-archive
851
851
  ```
852
852
 
853
+ ## `bdt ci plan`
854
+
855
+ Decides which tests a CI run executes, so every pipeline selects tests the same way (the dev -> test -> main flow of
856
+ the `branching-strategy` skill). Add the table to pyproject.toml (an empty one selects the defaults) and pin
857
+ `bmsdna-devtools` as a dev dependency:
858
+
859
+ ```toml
860
+ [tool.bdt.ci.plan]
861
+ on_missing = "infer" # PR without a usable label: infer from the changed files (default) or "fail"
862
+ non_code = ["docs/**", "*.md"] # files no test reads or ships; explicit per repo (full-match globs: `*.md` = root only, `**/*.md` = every folder)
863
+
864
+ [tool.bdt.ci.plan.tiers.unit] # default tiers: unit = tests/unit, e2e = tests/e2e (a tier whose folder is missing is skipped)
865
+ dir = "tests/unit" # areas = sub-folders with tests (test_*.py / *_test.py); `route_planning` <-> `test-route-planning`
866
+
867
+ [tool.bdt.ci.plan.tiers.e2e]
868
+ dir = "tests/e2e" # flat folder: map the test_*.py files to areas instead
869
+ areas = { chat = ["test_chat*.py"], review = ["test_review_*.py"] } # unmatched files form the area `shared`
870
+ ```
871
+
872
+ Labels on the PR: `test-<area>` (one per touched area, selects it in every tier), `test-shared` (just the area
873
+ `shared`), `test-full` (everything), `test-none` (no tests; lint and build still run). Legacy `e2e-<area>` labels count
874
+ as `test-<area>`. With the table present, `bdt pr create` requires one of them (group `test_area`, generated from the
875
+ same folders; a hand-written `test_area` group in `[tool.bdt.pr.required_labels]` wins). Folders without tests (output
876
+ folders like `test-results`, helper packages) are not areas; a changed file in one of them, or directly in the tier
877
+ folder, selects everything because any test may use it.
878
+
879
+ Order of decision, always logged with its reason:
880
+
881
+ 1. Not a PR: the CI run after a push (merge) to `test`/`main` runs nothing (that code was tested on its way into
882
+ `dev`); the nightly, manual runs (also on `test`/`main`), other branches and forks run everything. A manual run of
883
+ `refs/pull/<id>/merge` is planned like that PR (a fork PR still runs everything).
884
+ 2. Branch flow: promotions (`dev` -> `test`, `test` -> `main`, `main` -> `dev`) run nothing. `fix/*` / `hotfix/*` into
885
+ `main` are tested and must be cut from `main` (no unreleased `dev` commits). That check needs full history
886
+ (`fetchDepth: 0` on the checkout); in a shallow checkout or when the refs cannot be fetched it is skipped with a
887
+ warning instead of guessing. Any other PR into `test` / `main` fails the build. Area folders must not be called
888
+ `full` or `none` (they would collide with the labels).
889
+ 3. PR labels decide. No usable label (none, or only unknown `test-*` ones, which are warned about): the changed files
890
+ (merge commit against its first parent) decide. Nothing changed or only `non_code` files -> nothing runs, only area
891
+ tests -> those areas, any other file -> everything. `on_missing = "fail"` fails the build instead.
892
+ 4. Anything unreadable (label API, change list) runs everything: a missing decision never silently skips tests.
893
+
894
+ Outputs (Azure Pipelines variables with `isOutput=true`; plain `name=value` lines elsewhere): `runTests`, and per tier
895
+ (`unit`, `e2e`, ...) `<tier>Run`, `<tier>Areas` and `<tier>Paths`, a shell-quoted argument list for the runner. A
896
+ tier you do not configure and split by area (e.g. flat unit tests) is simply gated on `runTests`.
897
+
898
+ ```yaml
899
+ - stage: Plan
900
+ jobs:
901
+ - job: plan
902
+ steps:
903
+ - script: uv run bdt ci plan
904
+ name: plan # required for isOutput
905
+ env:
906
+ SYSTEM_ACCESSTOKEN: $(System.AccessToken) # reads the PR labels
907
+
908
+ - stage: Test
909
+ dependsOn: Plan
910
+ condition: and(succeeded(), eq(dependencies.Plan.outputs['plan.plan.unitRun'], 'true'))
911
+ variables:
912
+ unitPaths: $[ stageDependencies.Plan.plan.outputs['plan.unitPaths'] ]
913
+ jobs:
914
+ - job: unit
915
+ steps:
916
+ - script: uv run pytest --maxfail=3 $(unitPaths)
917
+ ```
918
+
919
+ Dry run locally: `uv run bdt ci plan --pr-source feat/x --pr-target dev --label test-orders` (or without `--label` to
920
+ see what the changed files select).
921
+
853
922
  ## `bdt lint`
854
923
 
855
924
  Static checks (implementing [bmsuisse/skills#52](https://github.com/bmsuisse/skills/issues/52))
@@ -0,0 +1,448 @@
1
+ """Which tests does this CI run execute? Pure decision logic behind `bdt ci plan` (I/O lives in `ci_plan_env`).
2
+
3
+ Configured under `[tool.bdt.ci.plan]` in pyproject.toml (the table must exist, even if empty; everything is optional):
4
+
5
+ [tool.bdt.ci.plan]
6
+ on_missing = "infer" # PR without a usable label: "infer" from the changed files (default) or "fail"
7
+ non_code = ["docs/**", "*.md"] # files no test reads or ships; explicit per repo (`*.md` = root only, `**/*.md` = all)
8
+
9
+ [tool.bdt.ci.plan.tiers.unit] # default tiers: unit = tests/unit, e2e = tests/e2e
10
+ dir = "tests/unit" # areas = sub-folders with tests (snake_case folder <-> kebab-case label)
11
+
12
+ [tool.bdt.ci.plan.tiers.e2e]
13
+ dir = "tests/e2e" # flat folder: an area table maps test files to areas instead
14
+ areas = { chat = ["test_chat*.py"], review = ["test_review_*.py"] } # unmatched files = area "shared"
15
+
16
+ Labels: `test-<area>`, `test-full` (everything), `test-none` (no tests; lint and build still run). The legacy
17
+ `e2e-<area>` labels count as `test-<area>`. Order of decision (the reason is always reported):
18
+
19
+ 1. Not a PR: the CI run after a push (merge) to `test`/`main` runs nothing (promoted code was tested on its way
20
+ into `dev`); anything else (nightly, manual runs, other branches) and forks run everything.
21
+ 2. Branch flow: promotions (dev -> test -> main -> dev) run nothing; `fix/*` and `hotfix/*` into `main` are tested
22
+ and must be cut from `main`; any other PR into `test`/`main` is an error.
23
+ 3. PR labels decide. Without a usable label the changed files decide (`on_missing = "infer"`): nothing changed or
24
+ only `non_code` files -> none, only area tests -> those areas, any other file -> everything.
25
+ 4. Anything unreadable (label API, change list) selects everything.
26
+ """
27
+
28
+ import shlex
29
+ from collections.abc import Callable, Collection, Iterable, Mapping
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path, PurePosixPath
32
+ from typing import Literal
33
+
34
+ from .bdt_config import find_pyproject, load_bdt_table
35
+
36
+ Kind = Literal["full", "none", "areas", "error"]
37
+ OnMissing = Literal["infer", "fail"]
38
+
39
+ LABEL_PREFIX = "test-"
40
+ LEGACY_LABEL_PREFIX = "e2e-"
41
+ FULL_LABEL = "test-full"
42
+ NONE_LABEL = "test-none"
43
+ DEFAULT_TIER_DIRS = {"unit": "tests/unit", "e2e": "tests/e2e"}
44
+ TEST_FILE_GLOB = "test_*.py"
45
+ UNMATCHED_AREA = "shared"
46
+ LONG_LIVED = frozenset({"dev", "test", "main"})
47
+ PROMOTIONS = frozenset({("dev", "test"), ("test", "main"), ("main", "dev")})
48
+ FIX_PREFIXES = ("fix/", "hotfix/")
49
+ PUSH_REASONS = frozenset(
50
+ {"IndividualCI", "BatchedCI"}
51
+ ) # a merge landed on the branch: deploy only
52
+ RESERVED_AREAS = frozenset(
53
+ {"full", "none"}
54
+ ) # test-full / test-none are the control labels
55
+
56
+
57
+ class BranchFlowError(Exception):
58
+ """A fix branch for `main` that was not cut from `main`."""
59
+
60
+
61
+ def _is_test_file(name: str) -> bool:
62
+ return any(PurePosixPath(name).match(g) for g in (TEST_FILE_GLOB, "*_test.py"))
63
+
64
+
65
+ def _has_tests(folder: Path) -> bool:
66
+ return any(
67
+ p.is_file()
68
+ and _is_test_file(p.name)
69
+ and "__pycache__" not in p.relative_to(folder).parts
70
+ for p in folder.rglob("*.py")
71
+ )
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Tier:
76
+ """One test tier (unit, e2e, ...). Areas are the sub-folders of `dir` that contain tests (`test_*.py` or
77
+ `*_test.py`; output and helper folders are not areas), or, with a `globs` table, groups of the `test_*.py` files
78
+ directly in `dir`."""
79
+
80
+ name: str
81
+ dir: str
82
+ globs: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
83
+
84
+ def _files(self, root: Path) -> list[str]:
85
+ base = root / self.dir
86
+ return sorted(
87
+ p.relative_to(root).as_posix()
88
+ for p in base.glob(TEST_FILE_GLOB)
89
+ if p.is_file()
90
+ )
91
+
92
+ def _owners(self, path: str) -> frozenset[str]:
93
+ name = PurePosixPath(path).name
94
+ owned = {
95
+ area
96
+ for area, globs in self.globs.items()
97
+ if any(PurePosixPath(name).full_match(g) for g in globs)
98
+ }
99
+ return frozenset(owned or {UNMATCHED_AREA})
100
+
101
+ def areas(self, root: Path) -> list[str]:
102
+ base = root / self.dir
103
+ if not base.is_dir():
104
+ return []
105
+ if self.globs:
106
+ return sorted(
107
+ {a for f in self._files(root) for a in self._owners(f)}
108
+ | set(self.globs)
109
+ )
110
+ return sorted(
111
+ d.name
112
+ for d in base.iterdir()
113
+ if d.is_dir() and not d.name.startswith(("_", ".")) and _has_tests(d)
114
+ )
115
+
116
+ def area_of(
117
+ self, root: Path, path: str, areas: Collection[str]
118
+ ) -> frozenset[str] | None:
119
+ """Areas owning a repo-relative file, or None when the file is not an area file of this tier (a helper
120
+ folder, a file directly in the tier folder): changing it can affect any test, so the plan runs everything.
121
+ In folder mode a file of an area folder counts, helpers like conftest.py included. A test file whose
122
+ whole folder was removed still names its (now gone) area. `areas` is `self.areas(root)`, computed once per
123
+ plan by the caller."""
124
+ pure = PurePosixPath(path)
125
+ if not pure.is_relative_to(self.dir):
126
+ return None
127
+ parts = pure.relative_to(self.dir).parts
128
+ if self.globs:
129
+ return (
130
+ self._owners(path)
131
+ if len(parts) == 1 and pure.match(TEST_FILE_GLOB)
132
+ else None
133
+ )
134
+ if len(parts) < 2 or parts[0].startswith(("_", ".")):
135
+ return None
136
+ removed = not (root / self.dir / parts[0]).exists() and _is_test_file(pure.name)
137
+ return frozenset({parts[0]}) if parts[0] in areas or removed else None
138
+
139
+ def paths(self, root: Path, selected: Collection[str] | None) -> list[str]:
140
+ """What the test runner gets: the selected areas' folders or files, or (None) the whole tier."""
141
+ if not (root / self.dir).is_dir():
142
+ return []
143
+ if self.globs:
144
+ return [
145
+ f
146
+ for f in self._files(root)
147
+ if selected is None or self._owners(f) & set(selected)
148
+ ]
149
+ if selected is None:
150
+ return [self.dir]
151
+ return [f"{self.dir}/{a}" for a in self.areas(root) if a in selected]
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class PlanConfig:
156
+ root: Path
157
+ tiers: tuple[Tier, ...]
158
+ on_missing: OnMissing = "infer"
159
+ non_code: tuple[str, ...] = ()
160
+
161
+ def all_areas(self) -> set[str]:
162
+ return {a for tier in self.tiers for a in tier.areas(self.root)}
163
+
164
+ def is_non_code(self, path: str) -> bool:
165
+ return any(PurePosixPath(path).full_match(g) for g in self.non_code)
166
+
167
+
168
+ def _strings(value: object, where: str) -> tuple[str, ...]:
169
+ items = (
170
+ tuple(v for v in value if isinstance(v, str)) if isinstance(value, list) else ()
171
+ )
172
+ if not isinstance(value, list) or len(items) != len(value):
173
+ raise ValueError(f"[tool.bdt.ci.plan] {where} must be a list of strings")
174
+ return items
175
+
176
+
177
+ def _tier(name: str, raw: object) -> Tier:
178
+ table = raw if isinstance(raw, dict) else {}
179
+ directory = table.get("dir", DEFAULT_TIER_DIRS.get(name))
180
+ areas = table.get("areas", {})
181
+ if not isinstance(directory, str):
182
+ raise ValueError(f"[tool.bdt.ci.plan.tiers.{name}] needs a string `dir`")
183
+ if not isinstance(areas, dict):
184
+ raise ValueError(
185
+ f"[tool.bdt.ci.plan.tiers.{name}] areas must be a table of glob lists"
186
+ )
187
+ globs = {
188
+ str(area): _strings(g, f"tiers.{name}.areas.{area}")
189
+ for area, g in areas.items()
190
+ }
191
+ return Tier(name, directory.strip("/"), globs)
192
+
193
+
194
+ def load_plan_config(start: Path | None = None) -> PlanConfig | None:
195
+ """`[tool.bdt.ci.plan]`, or None when the table is absent (the feature is opt-in per repo)."""
196
+ raw = load_bdt_table("ci", start).get("plan")
197
+ pyproject = find_pyproject(start)
198
+ if not isinstance(raw, dict) or pyproject is None:
199
+ return None
200
+ on_missing = raw.get("on_missing", "infer")
201
+ if on_missing not in ("infer", "fail"):
202
+ raise ValueError('[tool.bdt.ci.plan] on_missing must be "infer" or "fail"')
203
+ tiers_raw = raw.get("tiers", {name: {} for name in DEFAULT_TIER_DIRS})
204
+ if not isinstance(tiers_raw, dict):
205
+ raise ValueError("[tool.bdt.ci.plan.tiers] must be a table")
206
+ config = PlanConfig(
207
+ root=pyproject.parent,
208
+ tiers=tuple(_tier(name, t) for name, t in tiers_raw.items()),
209
+ on_missing=on_missing,
210
+ non_code=_strings(raw.get("non_code", []), "non_code"),
211
+ )
212
+ if clash := sorted(config.all_areas() & RESERVED_AREAS):
213
+ raise ValueError(
214
+ f"[tool.bdt.ci.plan] the area name(s) {', '.join(clash)} collide with the test-full / test-none labels; rename them"
215
+ )
216
+ return config
217
+
218
+
219
+ def area_label(area: str) -> str:
220
+ return LABEL_PREFIX + area.replace("_", "-")
221
+
222
+
223
+ def required_label_choices(config: PlanConfig) -> list[str]:
224
+ """Every label `bdt pr create` accepts as the PR's test decision (legacy `e2e-*` spellings included)."""
225
+ labels = [
226
+ *(area_label(a) for a in sorted(config.all_areas())),
227
+ FULL_LABEL,
228
+ NONE_LABEL,
229
+ ]
230
+ return [
231
+ *labels,
232
+ *(LEGACY_LABEL_PREFIX + label.removeprefix(LABEL_PREFIX) for label in labels),
233
+ ]
234
+
235
+
236
+ def normalize_labels(labels: Iterable[str]) -> set[str]:
237
+ """Lowercase (`bdt pr create` matches case-insensitively) and map legacy `e2e-*` to `test-*`."""
238
+ lowered = {label.lower() for label in labels}
239
+ return {
240
+ LABEL_PREFIX + x.removeprefix(LEGACY_LABEL_PREFIX)
241
+ if x.startswith(LEGACY_LABEL_PREFIX)
242
+ else x
243
+ for x in lowered
244
+ }
245
+
246
+
247
+ @dataclass(frozen=True)
248
+ class Context:
249
+ """What is being built: a PR (`pr_id` set) or a plain branch build."""
250
+
251
+ branch: str = ""
252
+ reason: str = "" # Azure's Build.Reason for branch builds (IndividualCI, BatchedCI, Schedule, Manual, ...)
253
+ pr_id: int | None = None
254
+ source: str = ""
255
+ target: str = ""
256
+ is_fork: bool = False
257
+
258
+
259
+ @dataclass(frozen=True)
260
+ class Plan:
261
+ kind: Kind
262
+ reason: str
263
+ areas: frozenset[str] = frozenset()
264
+ warnings: tuple[str, ...] = ()
265
+
266
+
267
+ def classify_pr(
268
+ source: str, target: str
269
+ ) -> tuple[Literal["promotion", "tested", "rejected"], str]:
270
+ """How a PR from `source` into `target` (branch names, no `refs/heads/`) is treated."""
271
+ if source in LONG_LIVED and target in LONG_LIVED:
272
+ if (source, target) in PROMOTIONS:
273
+ return "promotion", f"{source} -> {target}"
274
+ return (
275
+ "rejected",
276
+ f"{source} -> {target} is not a promotion (order is dev -> test -> main, then main -> dev)",
277
+ )
278
+ if target == "test":
279
+ return (
280
+ "rejected",
281
+ f"only the dev -> test promotion may target test, not {source}",
282
+ )
283
+ if target == "main":
284
+ if source.startswith(FIX_PREFIXES):
285
+ return "tested", f"fix branch {source} -> main"
286
+ return (
287
+ "rejected",
288
+ f"only fix/*, hotfix/* branches and the test -> main promotion may target main, not {source}",
289
+ )
290
+ return "tested", f"{source} -> {target}"
291
+
292
+
293
+ def _full(config: PlanConfig, reason: str, warnings: Iterable[str] = ()) -> Plan:
294
+ return Plan("full", reason, frozenset(config.all_areas()), tuple(warnings))
295
+
296
+
297
+ def _from_labels(
298
+ labels: Iterable[str], config: PlanConfig, warnings: list[str]
299
+ ) -> Plan | None:
300
+ normalized = normalize_labels(labels)
301
+ known = config.all_areas()
302
+ if FULL_LABEL in normalized:
303
+ return _full(config, f"PR has the {FULL_LABEL} label", warnings)
304
+ areas = frozenset(a for a in known if area_label(a) in normalized)
305
+ unknown = sorted(
306
+ x
307
+ for x in normalized
308
+ if x.startswith(LABEL_PREFIX)
309
+ and x not in {NONE_LABEL} | {area_label(a) for a in known}
310
+ )
311
+ if unknown:
312
+ warnings.append(f"unknown test label(s) ignored: {', '.join(unknown)}")
313
+ if NONE_LABEL in normalized and areas:
314
+ warnings.append(f"{NONE_LABEL} is ignored because area labels are present")
315
+ if areas:
316
+ return Plan(
317
+ "areas",
318
+ "PR labels select " + ", ".join(sorted(areas)),
319
+ areas,
320
+ tuple(warnings),
321
+ )
322
+ if NONE_LABEL in normalized:
323
+ return Plan("none", f"PR has the {NONE_LABEL} label", warnings=tuple(warnings))
324
+ return None
325
+
326
+
327
+ def _from_files(files: list[str], config: PlanConfig, warnings: list[str]) -> Plan:
328
+ if not files:
329
+ return Plan("none", "no changed files (a sync merge)", warnings=tuple(warnings))
330
+ known = {tier.name: frozenset(tier.areas(config.root)) for tier in config.tiers}
331
+ areas: set[str] = set()
332
+ for path in files:
333
+ if config.is_non_code(path):
334
+ continue
335
+ owners = [
336
+ owned
337
+ for tier in config.tiers
338
+ if (owned := tier.area_of(config.root, path, known[tier.name]))
339
+ ]
340
+ if not owners:
341
+ return _full(
342
+ config,
343
+ f"{path} is not owned by an area (shared code, config or an unmapped test)",
344
+ warnings,
345
+ )
346
+ areas.update(a for owned in owners for a in owned)
347
+ if not areas:
348
+ return Plan(
349
+ "none",
350
+ f"all {len(files)} changed file(s) are non-code",
351
+ warnings=tuple(warnings),
352
+ )
353
+ areas &= frozenset().union(
354
+ *known.values()
355
+ ) # a removed area has nothing left to run
356
+ if not areas:
357
+ return Plan(
358
+ "none",
359
+ "the changed area tests no longer exist (removed)",
360
+ warnings=tuple(warnings),
361
+ )
362
+ return Plan(
363
+ "areas",
364
+ f"changed area tests: {', '.join(sorted(areas))}",
365
+ frozenset(areas),
366
+ tuple(warnings),
367
+ )
368
+
369
+
370
+ def make_plan(
371
+ ctx: Context,
372
+ config: PlanConfig,
373
+ *,
374
+ labels: Callable[[], list[str]],
375
+ changed_files: Callable[[], list[str]],
376
+ verify_fix_base: Callable[[], None],
377
+ ) -> Plan:
378
+ """The decision. The three callables are the only I/O: PR labels, changed files, and the fix-branch base check
379
+ (raises BranchFlowError on a violation). Unreadable inputs (OSError, ValueError, RuntimeError) select everything."""
380
+ if ctx.pr_id is None:
381
+ if ctx.branch in {"test", "main"} and ctx.reason in PUSH_REASONS:
382
+ return Plan(
383
+ "none",
384
+ f"push to {ctx.branch}: that code was tested on its way into dev, deploy only",
385
+ )
386
+ return _full(config, "not a PR build")
387
+ if ctx.is_fork:
388
+ return _full(config, "PR from a fork")
389
+ kind, why = classify_pr(ctx.source, ctx.target)
390
+ if kind == "rejected":
391
+ return Plan("error", f"branch flow violated: {why}")
392
+ if kind == "promotion":
393
+ return Plan("none", f"promotion {why}: already tested, no tests run")
394
+ warnings: list[str] = []
395
+ if ctx.target == "main":
396
+ try:
397
+ verify_fix_base()
398
+ except BranchFlowError as error:
399
+ return Plan("error", f"branch flow violated: {error}")
400
+ except (OSError, ValueError, RuntimeError) as error:
401
+ warnings.append(f"fix-branch base not verified: {error}")
402
+ try:
403
+ found = _from_labels(labels(), config, warnings)
404
+ except (OSError, ValueError, RuntimeError) as error:
405
+ return _full(
406
+ config,
407
+ f"PR labels could not be read ({error}), running everything",
408
+ warnings,
409
+ )
410
+ if found:
411
+ return found
412
+ if config.on_missing == "fail":
413
+ known = ", ".join(area_label(a) for a in sorted(config.all_areas()))
414
+ return Plan(
415
+ "error",
416
+ f"PR has no test label: add one of {known}, {FULL_LABEL} or {NONE_LABEL}",
417
+ )
418
+ try:
419
+ files = changed_files()
420
+ except (OSError, ValueError, RuntimeError) as error:
421
+ return _full(
422
+ config,
423
+ f"no test label and the changed files could not be listed ({error})",
424
+ warnings,
425
+ )
426
+ warnings.append(
427
+ f"PR has no test label: areas inferred from the changed files (add a {LABEL_PREFIX}<area> label)"
428
+ )
429
+ return _from_files(files, config, warnings)
430
+
431
+
432
+ def outputs(plan: Plan, config: PlanConfig) -> dict[str, str]:
433
+ """Pipeline variables: `runTests`, then per tier `<tier>Run`, `<tier>Areas` and `<tier>Paths` (a shell-quoted
434
+ argument list for the test runner)."""
435
+ run = plan.kind in {"full", "areas"}
436
+ selected = None if plan.kind == "full" else plan.areas
437
+ result = {"runTests": str(run).lower()}
438
+ for tier in config.tiers:
439
+ paths = tier.paths(config.root, selected) if run else []
440
+ areas = (
441
+ [a for a in tier.areas(config.root) if selected is None or a in selected]
442
+ if run
443
+ else []
444
+ )
445
+ result[f"{tier.name}Run"] = str(bool(paths)).lower()
446
+ result[f"{tier.name}Areas"] = " ".join(areas)
447
+ result[f"{tier.name}Paths"] = shlex.join(paths)
448
+ return result