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