bmsdna-devtools 0.44.0__tar.gz → 0.46.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 (125) hide show
  1. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/PKG-INFO +32 -3
  2. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/README.md +31 -2
  3. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/bdt_config.py +5 -1
  4. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/cli.py +142 -8
  5. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/gh_pr.py +5 -0
  6. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/github_api.py +5 -0
  7. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_build.py +51 -5
  8. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_info.py +3 -0
  9. bmsdna_devtools-0.46.0/bmsdna/devtools/pr_ref.py +90 -0
  10. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/worktree.py +40 -1
  11. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/pyproject.toml +1 -1
  12. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/skills/bmsdna-devtools/SKILL.md +6 -1
  13. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_bdt_config.py +21 -0
  14. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_pr_id.py +49 -0
  15. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_build.py +4 -13
  16. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_build_flow.py +46 -0
  17. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_info.py +335 -2
  18. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_worktree_clean.py +66 -0
  19. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/uv.lock +1 -1
  20. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/.github/workflows/auto-release.yml +0 -0
  21. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/.github/workflows/python-publish.yml +0 -0
  22. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/.github/workflows/python-test.yml +0 -0
  23. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/.gitignore +0 -0
  24. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/.python-version +0 -0
  25. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/__init__.py +0 -0
  26. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/_api_dump.py +0 -0
  27. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/ado_auth.py +0 -0
  28. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/ado_issue.py +0 -0
  29. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/agent_mentions.py +0 -0
  30. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/api_usage.py +0 -0
  31. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/app_service_logs.py +0 -0
  32. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/azure_arm.py +0 -0
  33. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/azure_auth.py +0 -0
  34. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/cli_tools.py +0 -0
  35. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/commit.py +0 -0
  36. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/dead_code.py +0 -0
  37. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/env_config.py +0 -0
  38. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/env_files.py +0 -0
  39. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/find_injection.py +0 -0
  40. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/find_repo.py +0 -0
  41. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/gh_args.py +0 -0
  42. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/gh_issue.py +0 -0
  43. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/gitrepo.py +0 -0
  44. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/injection_frontend.py +0 -0
  45. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/injection_python.py +0 -0
  46. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/issue_do.py +0 -0
  47. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/issue_show.py +0 -0
  48. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/issue_watch.py +0 -0
  49. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint.py +0 -0
  50. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_findings.py +0 -0
  51. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_models.py +0 -0
  52. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_sql.py +0 -0
  53. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  54. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_tooling.py +0 -0
  55. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_typescript.py +0 -0
  56. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/logs.py +0 -0
  57. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_choice.py +0 -0
  58. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_create_args.py +0 -0
  59. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  60. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_labels.py +0 -0
  61. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_markdown.py +0 -0
  62. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pull.py +0 -0
  63. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/testdb.py +0 -0
  64. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/translate.py +0 -0
  65. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/__init__.py +0 -0
  66. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/cli.py +0 -0
  67. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/pipeline.py +0 -0
  68. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/pricing.py +0 -0
  69. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/report.py +0 -0
  70. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/00_schema.sql +0 -0
  71. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/01_ingest_claude.sql +0 -0
  72. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/01_ingest_codex.sql +0 -0
  73. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/02_messages.sql +0 -0
  74. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/03_markers.sql +0 -0
  75. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/04_facts.sql +0 -0
  76. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/05_views.sql +0 -0
  77. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/justfile +0 -0
  78. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/conftest.py +0 -0
  79. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/fake_github.py +0 -0
  80. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_ado_auth.py +0 -0
  81. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_ado_issue.py +0 -0
  82. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_ado_issue_flow.py +0 -0
  83. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_agent_mentions.py +0 -0
  84. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_api_usage.py +0 -0
  85. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_app_service_logs.py +0 -0
  86. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_azure_arm.py +0 -0
  87. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_cleanup.py +0 -0
  88. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_commit.py +0 -0
  89. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_issue.py +0 -0
  90. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_pr_create.py +0 -0
  91. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_pull.py +0 -0
  92. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_tools.py +0 -0
  93. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_version.py +0 -0
  94. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_commit.py +0 -0
  95. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_env_config.py +0 -0
  96. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_env_files.py +0 -0
  97. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_find_injection.py +0 -0
  98. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_find_repo.py +0 -0
  99. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_gh_issue.py +0 -0
  100. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_gh_pr.py +0 -0
  101. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_github_api.py +0 -0
  102. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_gitrepo.py +0 -0
  103. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_issue_do.py +0 -0
  104. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_issue_for_me.py +0 -0
  105. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_issue_show.py +0 -0
  106. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_issue_watch.py +0 -0
  107. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_lint.py +0 -0
  108. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_lint_models.py +0 -0
  109. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_lint_sql.py +0 -0
  110. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_lint_sql_files.py +0 -0
  111. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_lint_tooling.py +0 -0
  112. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_lint_typescript.py +0 -0
  113. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_logs.py +0 -0
  114. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_build_create_options.py +0 -0
  115. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_choice.py +0 -0
  116. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_create_args.py +0 -0
  117. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_description_not_truncated.py +0 -0
  118. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_issue_link.py +0 -0
  119. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_labels.py +0 -0
  120. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_markdown.py +0 -0
  121. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pull.py +0 -0
  122. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_testdb.py +0 -0
  123. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_translate.py +0 -0
  124. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_usage.py +0 -0
  125. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_wait_timeout.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bmsdna-devtools
3
- Version: 0.44.0
3
+ Version: 0.46.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
@@ -122,7 +122,7 @@ it watches a branch-triggered build/workflow run, not any particular PR.)
122
122
  ## `bdt pr info`
123
123
 
124
124
  ```bash
125
- bdt pr info [--json] [--pr-id N] [--target-branch BRANCH]
125
+ bdt pr info [--json] [--pr-id PR]... [--target-branch BRANCH] [--all]
126
126
  ```
127
127
 
128
128
  One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
@@ -133,6 +133,24 @@ the check rollup (only a failed check fails it; skipped/cancelled ones don't cou
133
133
  from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
134
134
  `waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked.
135
135
 
136
+ `--all` lists every open PR from the current branch (e.g. one into `dev` and one into `main`, which plain
137
+ `bdt pr info` refuses to pick between): with `--json` an array of the same objects, `[]` (exit 0) when there is
138
+ none; `--target-branch` narrows it, `--pr-id` can't be combined with it. Only open PRs are listed, and a PR with
139
+ merge conflicts is listed like any other (plain `pr info` on Azure DevOps errors on it).
140
+
141
+ `--pr-id` takes several PRs, also of other repos: repeat it and/or comma-separate (`--pr-id 12,34 --pr-id owner/repo#5`).
142
+ Each entry is a bare number (a PR of the current repo), `owner/repo#N` (GitHub), `project/repo#N` or
143
+ `org/project/repo#N` (Azure DevOps; the host is the current repo's, and the org is its org unless given), or the PR's
144
+ URL (GitHub, `dev.azure.com` or `*.visualstudio.com`; the only form that needs no git checkout). One plain number
145
+ behaves as before: the single object. One `owner/repo#N` is a single object too; two or more entries print a list in the
146
+ order given (duplicates once), as `--json` an array like `--all`'s, as text with the repo in each header. A failing entry
147
+ (unknown number, no access) doesn't hide the others: it is reported as `error: <entry>: <reason>` on stderr, the rest is
148
+ still printed, and the exit code is 1. An unreadable entry is a usage error before anything is looked up. `--pr-id` can't
149
+ be combined with `--all`; `--target-branch` is ignored for it, as before. Azure DevOps entries use one PAT/`az login` for
150
+ every org, and a PR with merge conflicts is an error entry there.
151
+
152
+ Every JSON object has a `repo` (`owner/repo` on GitHub, `org/project/repo` on Azure DevOps).
153
+
136
154
  Azure DevOps limits: the PR is found by the current branch (into `--target-branch` if given, else any target),
137
155
  a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed
138
156
  work items are only those whose URL is in the PR description -- not ones linked via
@@ -283,6 +301,15 @@ create prints a reminder to run `bdt pr status` afterward to check whether
283
301
  the CI build passes. This is a best-effort check — failures reading policy
284
302
  config (auth, permissions) fail open and simply skip the reminder.
285
303
 
304
+ ## `bdt pr comment`
305
+
306
+ ```bash
307
+ bdt pr comment --message "Looks good" [--screenshot shot.png] [--file report.pdf] [--pr-id 123]
308
+ bdt pr comment --message-file plan.md # post a file's text as the comment (`-` = stdin)
309
+ ```
310
+
311
+ Use `--message-file` to post a markdown document (an implementation plan, review findings) as the comment.
312
+
286
313
  ## `bdt issue create` / `show` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
287
314
 
288
315
  ```bash
@@ -559,7 +586,9 @@ scan_jinja = ["backend/print"] # "KEY" | tr in .jinja2/.j2/.html
559
586
  bdt worktree my-feature [--base dev] [--env-file .local_env] [--no-submodules] [--install "just install"]
560
587
  ```
561
588
 
562
- Creates `.worktrees/<name>` branched from `--base`, initializes submodules
589
+ Creates `.worktrees/<name>` branched from `--base`. It first fetches `origin/<base>` and, if the local
590
+ `<base>` is strictly behind it, branches from `origin/<base>` instead (so the PR starts on current code);
591
+ a local base that is ahead of or has diverged from the remote is kept as is. Then it initializes submodules
563
592
  (unless `--no-submodules`), and copies an env file into the new worktree as
564
593
  `.env` (auto-detects `.local_env` then `.env` if `--env-file` isn't given).
565
594
 
@@ -104,7 +104,7 @@ it watches a branch-triggered build/workflow run, not any particular PR.)
104
104
  ## `bdt pr info`
105
105
 
106
106
  ```bash
107
- bdt pr info [--json] [--pr-id N] [--target-branch BRANCH]
107
+ bdt pr info [--json] [--pr-id PR]... [--target-branch BRANCH] [--all]
108
108
  ```
109
109
 
110
110
  One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
@@ -115,6 +115,24 @@ the check rollup (only a failed check fails it; skipped/cancelled ones don't cou
115
115
  from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
116
116
  `waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked.
117
117
 
118
+ `--all` lists every open PR from the current branch (e.g. one into `dev` and one into `main`, which plain
119
+ `bdt pr info` refuses to pick between): with `--json` an array of the same objects, `[]` (exit 0) when there is
120
+ none; `--target-branch` narrows it, `--pr-id` can't be combined with it. Only open PRs are listed, and a PR with
121
+ merge conflicts is listed like any other (plain `pr info` on Azure DevOps errors on it).
122
+
123
+ `--pr-id` takes several PRs, also of other repos: repeat it and/or comma-separate (`--pr-id 12,34 --pr-id owner/repo#5`).
124
+ Each entry is a bare number (a PR of the current repo), `owner/repo#N` (GitHub), `project/repo#N` or
125
+ `org/project/repo#N` (Azure DevOps; the host is the current repo's, and the org is its org unless given), or the PR's
126
+ URL (GitHub, `dev.azure.com` or `*.visualstudio.com`; the only form that needs no git checkout). One plain number
127
+ behaves as before: the single object. One `owner/repo#N` is a single object too; two or more entries print a list in the
128
+ order given (duplicates once), as `--json` an array like `--all`'s, as text with the repo in each header. A failing entry
129
+ (unknown number, no access) doesn't hide the others: it is reported as `error: <entry>: <reason>` on stderr, the rest is
130
+ still printed, and the exit code is 1. An unreadable entry is a usage error before anything is looked up. `--pr-id` can't
131
+ be combined with `--all`; `--target-branch` is ignored for it, as before. Azure DevOps entries use one PAT/`az login` for
132
+ every org, and a PR with merge conflicts is an error entry there.
133
+
134
+ Every JSON object has a `repo` (`owner/repo` on GitHub, `org/project/repo` on Azure DevOps).
135
+
118
136
  Azure DevOps limits: the PR is found by the current branch (into `--target-branch` if given, else any target),
119
137
  a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed
120
138
  work items are only those whose URL is in the PR description -- not ones linked via
@@ -265,6 +283,15 @@ create prints a reminder to run `bdt pr status` afterward to check whether
265
283
  the CI build passes. This is a best-effort check — failures reading policy
266
284
  config (auth, permissions) fail open and simply skip the reminder.
267
285
 
286
+ ## `bdt pr comment`
287
+
288
+ ```bash
289
+ bdt pr comment --message "Looks good" [--screenshot shot.png] [--file report.pdf] [--pr-id 123]
290
+ bdt pr comment --message-file plan.md # post a file's text as the comment (`-` = stdin)
291
+ ```
292
+
293
+ Use `--message-file` to post a markdown document (an implementation plan, review findings) as the comment.
294
+
268
295
  ## `bdt issue create` / `show` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
269
296
 
270
297
  ```bash
@@ -541,7 +568,9 @@ scan_jinja = ["backend/print"] # "KEY" | tr in .jinja2/.j2/.html
541
568
  bdt worktree my-feature [--base dev] [--env-file .local_env] [--no-submodules] [--install "just install"]
542
569
  ```
543
570
 
544
- Creates `.worktrees/<name>` branched from `--base`, initializes submodules
571
+ Creates `.worktrees/<name>` branched from `--base`. It first fetches `origin/<base>` and, if the local
572
+ `<base>` is strictly behind it, branches from `origin/<base>` instead (so the PR starts on current code);
573
+ a local base that is ahead of or has diverged from the remote is kept as is. Then it initializes submodules
545
574
  (unless `--no-submodules`), and copies an env file into the new worktree as
546
575
  `.env` (auto-detects `.local_env` then `.env` if `--env-file` isn't given).
547
576
 
@@ -1,6 +1,8 @@
1
1
  """Shared lookup for `[tool.bdt.*]` configuration tables in the consuming
2
2
  repo's pyproject.toml (walks up from a start directory the same way `git`
3
- looks for `.git`, so it works from any subdirectory of the repo).
3
+ looks for `.git`, so it works from any subdirectory of the repo). The walk stops at the
4
+ repository root (the first directory holding a `.git` dir or file), so a submodule or nested
5
+ repo never picks up the config of the repo that contains it.
4
6
  """
5
7
 
6
8
  from __future__ import annotations
@@ -15,6 +17,8 @@ def find_pyproject(start: Path | None = None) -> Path | None:
15
17
  candidate = directory / "pyproject.toml"
16
18
  if candidate.is_file():
17
19
  return candidate
20
+ if (directory / ".git").exists():
21
+ return None
18
22
  return None
19
23
 
20
24
 
@@ -32,7 +32,7 @@ from . import find_injection as find_injection_mod
32
32
  from . import lint as lint_mod
33
33
  from . import logs as logs_mod
34
34
  from . import translate as translate_mod
35
- from . import pr_build, pr_info as pr_info_mod, pr_issue_link, pr_labels, pull as pull_mod, worktree as worktree_mod
35
+ from . import pr_build, pr_info as pr_info_mod, pr_issue_link, pr_ref, pr_labels, pull as pull_mod, worktree as worktree_mod
36
36
  from .ado_auth import auth_header
37
37
  from .bdt_config import find_pyproject, load_bdt_table
38
38
  from .cli_tools import CLI_TIMEOUT_SECS, EXIT_WAIT_TIMEOUT, detect_agent_session, require_gh
@@ -42,6 +42,7 @@ from .usage.cli import usage_app
42
42
  from .gitrepo import (
43
43
  AdoRemote,
44
44
  GitHubRemote,
45
+ NotAzureDevOpsRemoteError,
45
46
  NotGitHubRemoteError,
46
47
  UnknownRemoteError,
47
48
  current_branch,
@@ -170,6 +171,69 @@ def _current_pr_info(pr_id: int | None, target_branch: str | None, pat: str | No
170
171
  return pr_info_mod.ado_info(session, pr, remote)
171
172
 
172
173
 
174
+ def _open_prs_info(target_branch: str | None, pat: str | None) -> list[pr_info_mod.PrInfo]:
175
+ """`_current_pr_info` for every open PR from the current branch, `[]` if there is none."""
176
+ remote = current_remote()
177
+ if isinstance(remote, GitHubRemote):
178
+ gh = require_gh()
179
+ return [
180
+ pr_info_mod.github_info(gh_pr.get_pr(gh, number, fields=gh_pr.PR_INFO_FIELDS), remote)
181
+ for number in gh_pr.open_pr_numbers(gh, target_branch)
182
+ ]
183
+ session = requests.Session()
184
+ session.headers.update(auth_header(pat))
185
+ return [pr_info_mod.ado_info(session, pr, remote) for pr in pr_build.get_active_prs(session, remote, current_branch(), target_branch)]
186
+
187
+
188
+ _REMOTE_ERRORS = (UnknownRemoteError, NotGitHubRemoteError, NotAzureDevOpsRemoteError)
189
+
190
+
191
+ def _current_remote_or_none() -> AdoRemote | GitHubRemote | None:
192
+ """The current repo's remote, or None outside a checkout -- without git's own complaint, as that is no error here."""
193
+ r = subprocess.run(["git", "remote", "get-url", "origin"], capture_output=True, encoding="utf-8")
194
+ if r.returncode != 0:
195
+ return None
196
+ try:
197
+ return parse_remote(r.stdout.strip())
198
+ except _REMOTE_ERRORS:
199
+ return None
200
+
201
+
202
+ def _ref_pr_info(ref: pr_ref.PrRef, pat: str | None, clients: dict) -> pr_info_mod.PrInfo:
203
+ """The PR `ref` names, in its own repo (the current one for a bare number). `clients` caches the GitHub client
204
+ and Azure DevOps session across the entries of one call, so the token and auth are looked up once."""
205
+ remote = ref.remote or current_remote()
206
+ if isinstance(remote, GitHubRemote):
207
+ if "gh" not in clients:
208
+ clients["gh"] = require_gh()
209
+ base = clients["gh"]
210
+ gh = base if ref.remote is None else base.for_repo(remote.owner, remote.repo)
211
+ return pr_info_mod.github_info(gh_pr.get_pr(gh, ref.pr_id, fields=gh_pr.PR_INFO_FIELDS), remote)
212
+ if "session" not in clients:
213
+ clients["session"] = requests.Session()
214
+ clients["session"].headers.update(auth_header(pat))
215
+ session = clients["session"]
216
+ return pr_info_mod.ado_info(session, pr_build.get_pr_by_id(session, remote, ref.pr_id), remote)
217
+
218
+
219
+ def _failure_text(e: BaseException) -> str:
220
+ return str(e.code) if isinstance(e, SystemExit) and isinstance(e.code, str) else str(e) or type(e).__name__
221
+
222
+
223
+ def _ref_infos(refs: list[pr_ref.PrRef], pat: str | None) -> tuple[list[pr_info_mod.PrInfo], list[str]]:
224
+ """`_ref_pr_info` for each of `refs`, in order: the PRs found and one message per entry that failed, so one
225
+ unreachable repo or unknown number doesn't hide the others."""
226
+ clients: dict = {}
227
+ infos: list[pr_info_mod.PrInfo] = []
228
+ errors: list[str] = []
229
+ for ref in refs:
230
+ try:
231
+ infos.append(_ref_pr_info(ref, pat, clients))
232
+ except (SystemExit, requests.RequestException, KeyError, *_REMOTE_ERRORS) as e:
233
+ errors.append(f"{ref}: {_failure_text(e)}")
234
+ return infos, errors
235
+
236
+
173
237
  def _gh_branch_for(gh: GitHubClient, pr_id: int | None, *, needed: bool) -> str:
174
238
  """Branch to namespace uploaded screenshots/files under (see `gh_pr.push_assets`) --
175
239
  the checked-out branch normally, or (when acting on a PR by `--pr-id`, which may not
@@ -510,25 +574,85 @@ def pr_status(
510
574
  pr_build.run(remote, pat, target_branch, wait, pr_id=pr_id, timeout=timeout)
511
575
 
512
576
 
577
+ def _info_text(info: pr_info_mod.PrInfo, with_repo: bool = False) -> str:
578
+ flags = ", ".join([info.state, *(["draft"] if info.draft else []), f"build: {info.build}"])
579
+ name = f"PR {info.repo}#{info.number}" if with_repo and info.repo else f"PR #{info.number}"
580
+ return "\n".join([f"{name}: {info.title} ({flags})", info.url, *(f"Issue #{i.number}: {i.url}" for i in info.issues)])
581
+
582
+
583
+ def _echo_infos(infos: list[pr_info_mod.PrInfo], as_json: bool, with_repo: bool) -> None:
584
+ if as_json:
585
+ typer.echo(json.dumps([i.to_json_dict() for i in infos]))
586
+ elif infos:
587
+ typer.echo("\n\n".join(_info_text(i, with_repo) for i in infos))
588
+
589
+
513
590
  @pr_app.command("info")
514
591
  def pr_info(
515
592
  as_json: bool = typer.Option(False, "--json", help="Print machine-readable JSON (for status integrations that poll it)"),
516
593
  target_branch: str | None = typer.Option(None, "--target-branch", help=_LOOKUP_TARGET_HELP),
517
- pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
594
+ pr_ids: list[str] | None = typer.Option(
595
+ None,
596
+ "--pr-id",
597
+ help="Summarise this PR directly, instead of resolving it from the current git branch (lets you skip having its "
598
+ f"branch checked out locally). Repeat it (or comma-separate) for several, as a list; a PR of another repo is {pr_ref.FORMS}",
599
+ ),
600
+ all_prs: bool = typer.Option(
601
+ False,
602
+ "--all",
603
+ help="Every open PR from the current branch (e.g. one into dev and one into main) instead of exactly one: "
604
+ "with --json a list of the objects, [] and exit 0 when there is none",
605
+ ),
518
606
  pat: str | None = typer.Option(None, "--pat", envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"], help="Azure DevOps PAT (else uses DefaultAzureCredential, e.g. your `az login`)"),
519
607
  ) -> None:
520
608
  """One-shot summary of the PR opened from the current branch, or --pr-id directly: link, state,
521
609
  aggregate build state, and the issue(s)/work item(s) it closes. Exits 1 if there is no such PR.
522
610
  Build state is judged the way `pr status` does (latest build per pipeline on Azure DevOps).
611
+
612
+ --all and several --pr-id values list PRs instead (with --json an array, whatever the count).
523
613
  """
524
- info = _current_pr_info(pr_id, target_branch, pat)
614
+ refs: list[pr_ref.PrRef] = []
615
+ values: list[str] = []
616
+ if pr_ids:
617
+ values = pr_ref.split_values(pr_ids)
618
+ if not values:
619
+ raise typer.BadParameter("no PR given", param_hint="--pr-id")
620
+ try:
621
+ parsed = [pr_ref.parse_pr_ref(v, current_remote) for v in values]
622
+ except pr_ref.PrRefError as e:
623
+ raise typer.BadParameter(str(e), param_hint="--pr-id") from None
624
+ here = _current_remote_or_none() if any(r.remote for r in parsed) else None
625
+ # `5,5` and `5,owner/repo#5` are still a list of what they name once
626
+ unique: dict[str, pr_ref.PrRef] = {}
627
+ for ref in (pr_ref.canonical(r, here) for r in parsed):
628
+ unique.setdefault(str(ref).casefold(), ref)
629
+ refs = list(unique.values())
630
+ if all_prs and refs:
631
+ raise typer.BadParameter("--all lists the PRs of the current branch, it can't be combined with --pr-id", param_hint="--all")
632
+
633
+ if all_prs:
634
+ _echo_infos(_open_prs_info(target_branch, pat), as_json, with_repo=False)
635
+ return
636
+ if len(values) > 1:
637
+ infos, errors = _ref_infos(refs, pat)
638
+ _echo_infos(infos, as_json, with_repo=True)
639
+ for error in errors:
640
+ typer.echo(f"error: {error}", err=True)
641
+ if errors:
642
+ raise typer.Exit(1)
643
+ return
644
+ if refs and refs[0].remote is not None:
645
+ infos, errors = _ref_infos(refs, pat)
646
+ if errors:
647
+ typer.echo(f"error: {errors[0]}", err=True)
648
+ raise typer.Exit(1)
649
+ info = infos[0]
650
+ else:
651
+ info = _current_pr_info(refs[0].pr_id if refs else None, target_branch, pat)
525
652
  if as_json:
526
653
  typer.echo(json.dumps(info.to_json_dict()))
527
654
  return
528
- flags = ", ".join([info.state, *(["draft"] if info.draft else []), f"build: {info.build}"])
529
- typer.echo(f"PR #{info.number}: {info.title} ({flags})\n{info.url}")
530
- for issue in info.issues:
531
- typer.echo(f"Issue #{issue.number}: {issue.url}")
655
+ typer.echo(_info_text(info))
532
656
 
533
657
 
534
658
  @pr_app.command("retry")
@@ -629,6 +753,9 @@ def pr_update(
629
753
  @pr_app.command("comment")
630
754
  def pr_comment(
631
755
  message: str | None = typer.Option(None, "--message", help="Comment text"),
756
+ message_file: str | None = typer.Option(
757
+ None, "--message-file", help="Read the comment text from this file (`-` = stdin), e.g. a markdown plan"
758
+ ),
632
759
  screenshot: list[str] = typer.Option(
633
760
  [], "--screenshot", help="Path to an image to embed in the comment (repeatable)"
634
761
  ),
@@ -647,6 +774,13 @@ def pr_comment(
647
774
  """Post a comment on the PR opened from the current branch, or --pr-id directly
648
775
  (Azure DevOps or GitHub, auto-detected).
649
776
  """
777
+ if message_file is not None:
778
+ if message is not None:
779
+ raise typer.BadParameter("pass either --message or --message-file, not both", param_hint="--message-file")
780
+ try:
781
+ message = sys.stdin.read() if message_file == "-" else Path(message_file).read_text(encoding="utf-8")
782
+ except OSError as e:
783
+ raise typer.BadParameter(f"cannot read {message_file!r}: {e}", param_hint="--message-file") from e
650
784
  for path in screenshot:
651
785
  if not Path(path).is_file():
652
786
  raise typer.BadParameter(f"Screenshot not found: {path}", param_hint="--screenshot")
@@ -654,7 +788,7 @@ def pr_comment(
654
788
  if not Path(path).is_file():
655
789
  raise typer.BadParameter(f"File not found: {path}", param_hint="--file")
656
790
  if not message and not screenshot and not file:
657
- raise typer.BadParameter("Provide at least one of --message, --screenshot, --file")
791
+ raise typer.BadParameter("Provide at least one of --message, --message-file, --screenshot, --file")
658
792
 
659
793
  remote = current_remote()
660
794
  if isinstance(remote, GitHubRemote):
@@ -170,6 +170,11 @@ def resolve_pr_id(gh: GitHubClient, pr_id: int | None, target: str | None = None
170
170
  return prs[choose_pr(branch, candidates)]["number"]
171
171
 
172
172
 
173
+ def open_pr_numbers(gh: GitHubClient, target: str | None = None) -> list[int]:
174
+ """Numbers of every open PR from the current branch (into `target` if given), newest first; `[]` if there is none."""
175
+ return [p["number"] for p in _branch_prs(gh, current_branch(), state="open", base=target)]
176
+
177
+
173
178
  _CONTEXT_FIELDS = """
174
179
  fragment ContextFields on StatusCheckRollupContext {
175
180
  __typename
@@ -55,6 +55,7 @@ class GitHubClient:
55
55
 
56
56
  def __init__(self, token: str, owner: str | None = None, repo: str | None = None, base_url: str = API_URL) -> None:
57
57
  self.base_url = base_url.rstrip("/")
58
+ self._token = token
58
59
  self._owner = owner
59
60
  self._repo = repo
60
61
  self.session = requests.Session()
@@ -67,6 +68,10 @@ class GitHubClient:
67
68
  }
68
69
  )
69
70
 
71
+ def for_repo(self, owner: str, repo: str) -> GitHubClient:
72
+ """The same credentials, addressing another repo."""
73
+ return GitHubClient(self._token, owner, repo, self.base_url)
74
+
70
75
  @property
71
76
  def owner(self) -> str:
72
77
  self._resolve_repo()
@@ -207,6 +207,25 @@ def get_pr(session: requests.Session, remote: AdoRemote, source_branch: str, tar
207
207
  sys.exit(1)
208
208
 
209
209
 
210
+ def get_active_prs(session: requests.Session, remote: AdoRemote, source_branch: str, target_branch: str | None) -> list[dict]:
211
+ """Every active PR from `source_branch` (into `target_branch` if given); `[]` if there is none.
212
+
213
+ Unlike `get_pr` nothing is asked about and merge conflicts aren't an error: the caller wants the whole list.
214
+ """
215
+ r = session.get(
216
+ f"{_base_url(remote)}/_apis/git/repositories/{remote.repo}/pullrequests",
217
+ params={
218
+ "searchCriteria.sourceRefName": f"refs/heads/{source_branch}",
219
+ **({"searchCriteria.targetRefName": f"refs/heads/{target_branch}"} if target_branch else {}),
220
+ "searchCriteria.status": "active",
221
+ "api-version": "7.1",
222
+ },
223
+ timeout=HTTP_TIMEOUT_SECS,
224
+ )
225
+ r.raise_for_status()
226
+ return r.json().get("value", [])
227
+
228
+
210
229
  def get_pr_by_id(session: requests.Session, remote: AdoRemote, pr_id: int) -> dict:
211
230
  """Fetch a PR directly by its ID -- for `--pr-id` overrides, where the caller wants to act
212
231
  on a specific PR without needing its source branch checked out locally (unlike `get_pr`,
@@ -561,13 +580,40 @@ def update(
561
580
  def link_work_item(session: requests.Session, remote: AdoRemote, pr_id: int, work_item_id: int) -> None:
562
581
  """Link work item `work_item_id` to PR `pr_id`, so it shows up in the PR's "Work Items" tab
563
582
  (and the work item's own "Development" links) -- the Azure DevOps equivalent of GitHub's
564
- closing-keyword PR<->issue link, via the dedicated Pull Request Work Items REST resource
565
- (POST .../pullRequests/{pr_id}/workitems/{work_item_id}, the same one `az repos pr work-item
566
- add` itself calls). Idempotent -- linking an already-linked work item just re-confirms it.
583
+ closing-keyword PR<->issue link.
584
+
585
+ There is no REST resource that links "PR -> work item" directly; what `az repos pr work-item add`
586
+ does (and so do we) is add an `ArtifactLink` relation to the *work item*, pointing at the PR's
587
+ `vstfs:///Git/PullRequestId/{projectId}%2F{repoId}%2F{prId}` artifact URI. Idempotent -- an
588
+ already-linked work item is left alone (adding the relation twice would be rejected).
567
589
  """
568
- r = session.post(
569
- f"{_base_url(remote)}/_apis/git/repositories/{quote(remote.repo, safe='')}/pullRequests/{pr_id}/workitems/{work_item_id}",
590
+ r = session.get(
591
+ f"{_base_url(remote)}/_apis/git/repositories/{quote(remote.repo, safe='')}/pullrequests/{pr_id}",
592
+ params={"api-version": "7.1"},
593
+ timeout=HTTP_TIMEOUT_SECS,
594
+ )
595
+ r.raise_for_status()
596
+ repository = r.json()["repository"]
597
+ artifact_url = f"vstfs:///Git/PullRequestId/{repository['project']['id']}%2F{repository['id']}%2F{pr_id}"
598
+
599
+ work_item_url = f"{_base_url(remote)}/_apis/wit/workitems/{work_item_id}"
600
+ r = session.get(work_item_url, params={"$expand": "relations", "api-version": "7.1"}, timeout=HTTP_TIMEOUT_SECS)
601
+ r.raise_for_status()
602
+ existing = r.json().get("relations") or []
603
+ if any(rel.get("rel") == "ArtifactLink" and str(rel.get("url", "")).lower() == artifact_url.lower() for rel in existing):
604
+ return
605
+
606
+ r = session.patch(
607
+ work_item_url,
570
608
  params={"api-version": "7.1"},
609
+ json=[
610
+ {
611
+ "op": "add",
612
+ "path": "/relations/-",
613
+ "value": {"rel": "ArtifactLink", "url": artifact_url, "attributes": {"name": "Pull Request"}},
614
+ }
615
+ ],
616
+ headers={"Content-Type": "application/json-patch+json"},
571
617
  timeout=HTTP_TIMEOUT_SECS,
572
618
  )
573
619
  r.raise_for_status()
@@ -40,6 +40,7 @@ class PrInfo:
40
40
  draft: bool
41
41
  build: str
42
42
  issues: list[IssueRef] = field(default_factory=list)
43
+ repo: str = "" # "owner/repo" (GitHub) or "org/project/repo" (Azure DevOps): which repo the PR is in
43
44
 
44
45
  def to_json_dict(self) -> dict:
45
46
  return asdict(self)
@@ -95,6 +96,7 @@ def github_info(pr: dict, remote: GitHubRemote) -> PrInfo:
95
96
  draft=bool(pr.get("isDraft")),
96
97
  build=build_state(pr.get("statusCheckRollup") or []),
97
98
  issues=issues,
99
+ repo=f"{remote.owner}/{remote.repo}",
98
100
  )
99
101
 
100
102
 
@@ -143,4 +145,5 @@ def ado_info(session: requests.Session, pr: dict, remote: AdoRemote) -> PrInfo:
143
145
  draft=bool(pr.get("isDraft")),
144
146
  build=_ado_build(session, remote, pr),
145
147
  issues=issues,
148
+ repo=f"{remote.org}/{remote.project}/{remote.repo}",
146
149
  )
@@ -0,0 +1,90 @@
1
+ """`bdt pr info --pr-id` values: which PR, in which repo.
2
+
3
+ A bare number is a PR of the current repo. For a PR of another repo (a status integration or an agent working
4
+ across repos) an entry carries the repo too: `owner/repo#12` on GitHub, `project/repo#12` or `org/project/repo#12` on
5
+ Azure DevOps (the host being the current repo's), or the PR's full web URL, which needs no checkout at all.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+
14
+ from .gitrepo import AdoRemote, GitHubRemote, NotAzureDevOpsRemoteError, parse_ado_remote
15
+
16
+ FORMS = "a number, owner/repo#N (GitHub), project/repo#N or org/project/repo#N (Azure DevOps), or the PR's URL"
17
+
18
+
19
+ class PrRefError(ValueError):
20
+ pass
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class PrRef:
25
+ pr_id: int
26
+ remote: AdoRemote | GitHubRemote | None # None: the current repo
27
+
28
+ def __str__(self) -> str:
29
+ if isinstance(self.remote, GitHubRemote):
30
+ return f"{self.remote.owner}/{self.remote.repo}#{self.pr_id}"
31
+ if isinstance(self.remote, AdoRemote):
32
+ return f"{self.remote.org}/{self.remote.project}/{self.remote.repo}#{self.pr_id}"
33
+ return f"#{self.pr_id}"
34
+
35
+
36
+ _GITHUB_PR_URL = re.compile(r"^https://github\.com/([^/\s]+)/([^/\s]+)/pull/(\d+)(?:[/?#].*)?$")
37
+ _ADO_PR_URL = re.compile(r"^https://(?:dev\.azure\.com/|[^/\s]+\.visualstudio\.com/).*?/_git/[^/\s]+/pullrequest/(\d+)(?:[/?#].*)?$")
38
+ _SHORTHAND = re.compile(r"^(.+)#(\d+)$")
39
+
40
+
41
+ def split_values(values: list[str]) -> list[str]:
42
+ """Entries of repeated and/or comma-separated `--pr-id` values, blanks dropped."""
43
+ return [part.strip() for value in values for part in value.split(",") if part.strip()]
44
+
45
+
46
+ def parse_pr_ref(text: str, current_remote: Callable[[], AdoRemote | GitHubRemote]) -> PrRef:
47
+ """`current_remote` is only called for the `repo#N` forms, which take their host from it."""
48
+ text = text.strip()
49
+ if re.fullmatch(r"#?\d+", text):
50
+ return PrRef(int(text.lstrip("#")), None)
51
+
52
+ if match := _GITHUB_PR_URL.match(text):
53
+ return PrRef(int(match.group(3)), GitHubRemote(match.group(1), match.group(2)))
54
+ if match := _ADO_PR_URL.match(text):
55
+ # the legacy `https://org.visualstudio.com/DefaultCollection/Project/_git/...` form has a collection segment
56
+ url = text.replace("/DefaultCollection/", "/", 1) if "visualstudio.com" in text else text
57
+ try:
58
+ return PrRef(int(match.group(1)), parse_ado_remote(url))
59
+ except (NotAzureDevOpsRemoteError, IndexError):
60
+ raise PrRefError(f"can't read the Azure DevOps repo from '{text}'") from None
61
+
62
+ if match := _SHORTHAND.match(text):
63
+ parts = match.group(1).split("/")
64
+ pr_id = int(match.group(2))
65
+ if all(parts) and len(parts) in (2, 3):
66
+ try:
67
+ here = current_remote()
68
+ except (SystemExit, Exception) as e:
69
+ raise PrRefError(
70
+ f"'{text}' needs to know whether this repo is GitHub or Azure DevOps ({e}); use the PR's URL instead"
71
+ ) from None
72
+ if isinstance(here, GitHubRemote) and len(parts) == 2:
73
+ return PrRef(pr_id, GitHubRemote(parts[0], parts[1]))
74
+ if isinstance(here, AdoRemote):
75
+ if len(parts) == 2:
76
+ return PrRef(pr_id, AdoRemote(here.org, parts[0], parts[1]))
77
+ return PrRef(pr_id, AdoRemote(parts[0], parts[1], parts[2]))
78
+ raise PrRefError(f"'{text}': GitHub PRs are owner/repo#N")
79
+ raise PrRefError(f"can't read '{text}' as a PR: use {FORMS}")
80
+
81
+
82
+ def canonical(ref: PrRef, here: AdoRemote | GitHubRemote | None) -> PrRef:
83
+ """`ref` as a bare number when it names a PR of the current repo `here` (compared case-insensitively), so that
84
+ `5` and `owner/repo#5` are one PR; `here` None (no checkout) leaves it as it is."""
85
+ if ref.remote is None or here is None or type(ref.remote) is not type(here):
86
+ return ref
87
+ same = [str(getattr(ref.remote, f)).casefold() for f in ref.remote.__dataclass_fields__] == [
88
+ str(getattr(here, f)).casefold() for f in here.__dataclass_fields__
89
+ ]
90
+ return PrRef(ref.pr_id, None) if same else ref
@@ -59,6 +59,43 @@ def _run_capture(cmd: list[str], cwd: Path | None = None) -> subprocess.Complete
59
59
  return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=False)
60
60
 
61
61
 
62
+ def _git_ok(args: list[str], cwd: Path, timeout: float = 5.0) -> bool:
63
+ try:
64
+ return subprocess.run(["git", *args], cwd=cwd, capture_output=True, check=False, timeout=timeout).returncode == 0
65
+ except (FileNotFoundError, subprocess.TimeoutExpired):
66
+ return False
67
+
68
+
69
+ def _start_point(root: Path, base: str) -> tuple[str, bool]:
70
+ """Where a new worktree branch should start, and whether that is a remote-tracking ref.
71
+
72
+ `base` is usually a long-lived local branch (`dev`) that nobody keeps up to date, so a worktree
73
+ branched from it starts behind -- the PR then shows merge conflicts and the plan gets written
74
+ against stale code. Fetch `origin/<base>` first and start from it when the local branch is
75
+ strictly behind. A local branch that is ahead of / diverged from the remote is left alone (its
76
+ unpushed work is on purpose), as is everything when there is no `origin` or it can't be reached.
77
+ """
78
+ local = f"refs/heads/{base}"
79
+ remote = f"refs/remotes/origin/{base}"
80
+ has_local = _git_ok(["rev-parse", "--verify", "--quiet", local], root)
81
+ if not _git_ok(["remote", "get-url", "origin"], root):
82
+ return base, False
83
+ if not _git_ok(["fetch", "--quiet", "origin", f"+refs/heads/{base}:{remote}"], root, timeout=pull_mod.CLI_TIMEOUT_SECS):
84
+ if has_local:
85
+ print(f"warning: could not fetch origin/{base}; branching from the local {base}, which may be behind", file=sys.stderr)
86
+ return base, False
87
+ if not has_local:
88
+ return f"origin/{base}", True
89
+ if _git_ok(["merge-base", "--is-ancestor", remote, local], root):
90
+ return base, False # up to date or ahead of the remote
91
+ if _git_ok(["merge-base", "--is-ancestor", local, remote], root):
92
+ behind = _run_capture(["git", "rev-list", "--count", f"{local}..{remote}"], cwd=root).stdout.strip()
93
+ print(f"{base} is {behind} commit(s) behind origin/{base}; branching from origin/{base}")
94
+ return f"origin/{base}", True
95
+ print(f"warning: local {base} and origin/{base} have diverged; branching from the local {base}", file=sys.stderr)
96
+ return base, False
97
+
98
+
62
99
  def create(
63
100
  name: str,
64
101
  base: str = "dev",
@@ -72,7 +109,9 @@ def create(
72
109
  if path.exists():
73
110
  sys.exit(f"error: {path} already exists")
74
111
 
75
- _run(["git", "worktree", "add", str(path), "-b", name, base], cwd=root)
112
+ start, remote_start = _start_point(root, base)
113
+ # --no-track: the new branch must not push to / compare against origin/<base>
114
+ _run(["git", "worktree", "add", *(["--no-track"] if remote_start else []), str(path), "-b", name, start], cwd=root)
76
115
 
77
116
  if submodules and (root / ".gitmodules").exists():
78
117
  _run(["git", "submodule", "update", "--init"], cwd=path)
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.44.0"
13
+ version = "0.46.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