bmsdna-devtools 0.45.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.45.0/README.md → bmsdna_devtools-0.46.0/PKG-INFO +38 -3
  2. bmsdna_devtools-0.45.0/PKG-INFO → bmsdna_devtools-0.46.0/README.md +20 -21
  3. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/cli.py +133 -9
  4. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/gh_pr.py +5 -0
  5. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/github_api.py +5 -0
  6. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_build.py +19 -0
  7. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_info.py +3 -0
  8. bmsdna_devtools-0.46.0/bmsdna/devtools/pr_ref.py +90 -0
  9. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/pyproject.toml +1 -1
  10. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/skills/bmsdna-devtools/SKILL.md +6 -1
  11. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_pr_info.py +335 -2
  12. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/uv.lock +1 -1
  13. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/.github/workflows/auto-release.yml +0 -0
  14. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/.github/workflows/python-publish.yml +0 -0
  15. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/.github/workflows/python-test.yml +0 -0
  16. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/.gitignore +0 -0
  17. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/.python-version +0 -0
  18. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/__init__.py +0 -0
  19. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/_api_dump.py +0 -0
  20. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/ado_auth.py +0 -0
  21. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/ado_issue.py +0 -0
  22. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/agent_mentions.py +0 -0
  23. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/api_usage.py +0 -0
  24. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/app_service_logs.py +0 -0
  25. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/azure_arm.py +0 -0
  26. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/azure_auth.py +0 -0
  27. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/bdt_config.py +0 -0
  28. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/cli_tools.py +0 -0
  29. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/commit.py +0 -0
  30. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/dead_code.py +0 -0
  31. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/env_config.py +0 -0
  32. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/env_files.py +0 -0
  33. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/find_injection.py +0 -0
  34. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/find_repo.py +0 -0
  35. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/gh_args.py +0 -0
  36. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/gh_issue.py +0 -0
  37. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/gitrepo.py +0 -0
  38. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/injection_frontend.py +0 -0
  39. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/injection_python.py +0 -0
  40. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/issue_do.py +0 -0
  41. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/issue_show.py +0 -0
  42. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/issue_watch.py +0 -0
  43. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint.py +0 -0
  44. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_findings.py +0 -0
  45. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_models.py +0 -0
  46. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_sql.py +0 -0
  47. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  48. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_tooling.py +0 -0
  49. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_typescript.py +0 -0
  50. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/logs.py +0 -0
  51. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_choice.py +0 -0
  52. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_create_args.py +0 -0
  53. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  54. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_labels.py +0 -0
  55. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_markdown.py +0 -0
  56. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pull.py +0 -0
  57. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/testdb.py +0 -0
  58. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/translate.py +0 -0
  59. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/__init__.py +0 -0
  60. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/cli.py +0 -0
  61. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/pipeline.py +0 -0
  62. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/pricing.py +0 -0
  63. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/report.py +0 -0
  64. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/00_schema.sql +0 -0
  65. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/01_ingest_claude.sql +0 -0
  66. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/01_ingest_codex.sql +0 -0
  67. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/02_messages.sql +0 -0
  68. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/03_markers.sql +0 -0
  69. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/04_facts.sql +0 -0
  70. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/05_views.sql +0 -0
  71. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/worktree.py +0 -0
  72. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/justfile +0 -0
  73. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/conftest.py +0 -0
  74. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/fake_github.py +0 -0
  75. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_ado_auth.py +0 -0
  76. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_ado_issue.py +0 -0
  77. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_ado_issue_flow.py +0 -0
  78. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_agent_mentions.py +0 -0
  79. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_api_usage.py +0 -0
  80. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_app_service_logs.py +0 -0
  81. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_azure_arm.py +0 -0
  82. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_bdt_config.py +0 -0
  83. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_cli_cleanup.py +0 -0
  84. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_cli_commit.py +0 -0
  85. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_cli_issue.py +0 -0
  86. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_cli_pr_create.py +0 -0
  87. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_cli_pr_id.py +0 -0
  88. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_cli_pull.py +0 -0
  89. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_cli_tools.py +0 -0
  90. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_cli_version.py +0 -0
  91. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_commit.py +0 -0
  92. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_env_config.py +0 -0
  93. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_env_files.py +0 -0
  94. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_find_injection.py +0 -0
  95. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_find_repo.py +0 -0
  96. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_gh_issue.py +0 -0
  97. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_gh_pr.py +0 -0
  98. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_github_api.py +0 -0
  99. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_gitrepo.py +0 -0
  100. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_issue_do.py +0 -0
  101. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_issue_for_me.py +0 -0
  102. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_issue_show.py +0 -0
  103. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_issue_watch.py +0 -0
  104. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_lint.py +0 -0
  105. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_lint_models.py +0 -0
  106. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_lint_sql.py +0 -0
  107. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_lint_sql_files.py +0 -0
  108. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_lint_tooling.py +0 -0
  109. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_lint_typescript.py +0 -0
  110. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_logs.py +0 -0
  111. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_pr_build.py +0 -0
  112. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_pr_build_create_options.py +0 -0
  113. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_pr_build_flow.py +0 -0
  114. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_pr_choice.py +0 -0
  115. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_pr_create_args.py +0 -0
  116. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_pr_description_not_truncated.py +0 -0
  117. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_pr_issue_link.py +0 -0
  118. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_pr_labels.py +0 -0
  119. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_pr_markdown.py +0 -0
  120. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_pull.py +0 -0
  121. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_testdb.py +0 -0
  122. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_translate.py +0 -0
  123. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_usage.py +0 -0
  124. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_wait_timeout.py +0 -0
  125. {bmsdna_devtools-0.45.0 → bmsdna_devtools-0.46.0}/tests/test_worktree_clean.py +0 -0
@@ -1,3 +1,21 @@
1
+ Metadata-Version: 2.5
2
+ Name: bmsdna-devtools
3
+ Version: 0.46.0
4
+ Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: azure-identity>=1.19.0
7
+ Requires-Dist: pgdevkit[db]>=0.7.1
8
+ Requires-Dist: requests>=2.32.0
9
+ Requires-Dist: rich>=13.0.0
10
+ Requires-Dist: tomli-w>=1.0.0
11
+ Requires-Dist: typer>=0.26.7
12
+ Provides-Extra: lint
13
+ Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
14
+ Provides-Extra: usage
15
+ Requires-Dist: duckdb>=1.3.0; extra == 'usage'
16
+ Requires-Dist: plotly>=5.24.0; extra == 'usage'
17
+ Description-Content-Type: text/markdown
18
+
1
19
  # bmsdna-devtools
2
20
 
3
21
  Shared developer tooling for BMS projects: PR build/check status and a
@@ -104,7 +122,7 @@ it watches a branch-triggered build/workflow run, not any particular PR.)
104
122
  ## `bdt pr info`
105
123
 
106
124
  ```bash
107
- bdt pr info [--json] [--pr-id N] [--target-branch BRANCH]
125
+ bdt pr info [--json] [--pr-id PR]... [--target-branch BRANCH] [--all]
108
126
  ```
109
127
 
110
128
  One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
@@ -115,6 +133,24 @@ the check rollup (only a failed check fails it; skipped/cancelled ones don't cou
115
133
  from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
116
134
  `waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked.
117
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
+
118
154
  Azure DevOps limits: the PR is found by the current branch (into `--target-branch` if given, else any target),
119
155
  a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed
120
156
  work items are only those whose URL is in the PR description -- not ones linked via
@@ -272,8 +308,7 @@ bdt pr comment --message "Looks good" [--screenshot shot.png] [--file report.pdf
272
308
  bdt pr comment --message-file plan.md # post a file's text as the comment (`-` = stdin)
273
309
  ```
274
310
 
275
- `--file` links the file as an *attachment* (a download link in the comment); it does not post the file's
276
- content. To post a markdown document (an implementation plan, review findings) use `--message-file`.
311
+ Use `--message-file` to post a markdown document (an implementation plan, review findings) as the comment.
277
312
 
278
313
  ## `bdt issue create` / `show` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
279
314
 
@@ -1,21 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: bmsdna-devtools
3
- Version: 0.45.0
4
- Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
- Requires-Python: >=3.14
6
- Requires-Dist: azure-identity>=1.19.0
7
- Requires-Dist: pgdevkit[db]>=0.7.1
8
- Requires-Dist: requests>=2.32.0
9
- Requires-Dist: rich>=13.0.0
10
- Requires-Dist: tomli-w>=1.0.0
11
- Requires-Dist: typer>=0.26.7
12
- Provides-Extra: lint
13
- Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
14
- Provides-Extra: usage
15
- Requires-Dist: duckdb>=1.3.0; extra == 'usage'
16
- Requires-Dist: plotly>=5.24.0; extra == 'usage'
17
- Description-Content-Type: text/markdown
18
-
19
1
  # bmsdna-devtools
20
2
 
21
3
  Shared developer tooling for BMS projects: PR build/check status and a
@@ -122,7 +104,7 @@ it watches a branch-triggered build/workflow run, not any particular PR.)
122
104
  ## `bdt pr info`
123
105
 
124
106
  ```bash
125
- bdt pr info [--json] [--pr-id N] [--target-branch BRANCH]
107
+ bdt pr info [--json] [--pr-id PR]... [--target-branch BRANCH] [--all]
126
108
  ```
127
109
 
128
110
  One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
@@ -133,6 +115,24 @@ the check rollup (only a failed check fails it; skipped/cancelled ones don't cou
133
115
  from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
134
116
  `waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked.
135
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
+
136
136
  Azure DevOps limits: the PR is found by the current branch (into `--target-branch` if given, else any target),
137
137
  a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed
138
138
  work items are only those whose URL is in the PR description -- not ones linked via
@@ -290,8 +290,7 @@ bdt pr comment --message "Looks good" [--screenshot shot.png] [--file report.pdf
290
290
  bdt pr comment --message-file plan.md # post a file's text as the comment (`-` = stdin)
291
291
  ```
292
292
 
293
- `--file` links the file as an *attachment* (a download link in the comment); it does not post the file's
294
- content. To post a markdown document (an implementation plan, review findings) use `--message-file`.
293
+ Use `--message-file` to post a markdown document (an implementation plan, review findings) as the comment.
295
294
 
296
295
  ## `bdt issue create` / `show` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
297
296
 
@@ -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")
@@ -630,13 +754,13 @@ def pr_update(
630
754
  def pr_comment(
631
755
  message: str | None = typer.Option(None, "--message", help="Comment text"),
632
756
  message_file: str | None = typer.Option(
633
- None, "--message-file", help="Read the comment text from this file (`-` = stdin); unlike --file, the content is posted as the comment"
757
+ None, "--message-file", help="Read the comment text from this file (`-` = stdin), e.g. a markdown plan"
634
758
  ),
635
759
  screenshot: list[str] = typer.Option(
636
760
  [], "--screenshot", help="Path to an image to embed in the comment (repeatable)"
637
761
  ),
638
762
  file: list[str] = typer.Option(
639
- [], "--file", help="Path to a file to link in the comment as an attachment (repeatable); to post a file's text as the comment use --message-file"
763
+ [], "--file", help="Path to an arbitrary file to link in the comment as an attachment (repeatable)"
640
764
  ),
641
765
  target: str | None = typer.Option(None, "--target", help=_LOOKUP_TARGET_HELP),
642
766
  pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
@@ -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`,
@@ -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
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.45.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
@@ -141,11 +141,16 @@ the command's own timeout (or run it in the background) longer than `--timeout`.
141
141
  ## PR summary
142
142
 
143
143
  ```bash
144
- bdt pr info [--json]
144
+ bdt pr info [--json] [--all] [--pr-id PR]...
145
145
  ```
146
146
 
147
147
  PR link, state, aggregate build state and the issue(s) it closes, in one call -- cheaper than
148
148
  `bdt pr status` when you only need to know where things stand. Exits 1 if the branch has no PR.
149
+ `--all` lists every open PR of the branch instead (a JSON array with `--json`, `[]` when there is none), for a branch
150
+ with several PRs, e.g. into `dev` and `main`, where plain `pr info` has to ask which one.
151
+ `--pr-id` (repeatable, comma-separated) names PRs directly, also of other repos: a number, `owner/repo#N` (GitHub),
152
+ `project/repo#N` (Azure DevOps) or the PR's URL; two or more give a list, and a failing entry is an `error:` line on
153
+ stderr with exit 1 while the others are still printed. Every JSON object carries its `repo`.
149
154
  (`bdt pr status --wait` is still what to use to wait for CI.)
150
155
 
151
156
  ## Creating a worktree