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.
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/PKG-INFO +32 -3
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/README.md +31 -2
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/bdt_config.py +5 -1
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/cli.py +142 -8
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/gh_pr.py +5 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/github_api.py +5 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_build.py +51 -5
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_info.py +3 -0
- bmsdna_devtools-0.46.0/bmsdna/devtools/pr_ref.py +90 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/worktree.py +40 -1
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/skills/bmsdna-devtools/SKILL.md +6 -1
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_bdt_config.py +21 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_pr_id.py +49 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_build.py +4 -13
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_build_flow.py +46 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_info.py +335 -2
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_worktree_clean.py +66 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/uv.lock +1 -1
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/.gitignore +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/.python-version +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/_api_dump.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/ado_issue.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/agent_mentions.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/api_usage.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/azure_arm.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/azure_auth.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/dead_code.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/env_files.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/find_injection.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/gh_args.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/issue_do.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/issue_show.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/issue_watch.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_sql.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_sql_files.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_choice.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_create_args.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/translate.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/__init__.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/cli.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/pipeline.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/pricing.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/report.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/00_schema.sql +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/01_ingest_claude.sql +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/01_ingest_codex.sql +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/02_messages.sql +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/03_markers.sql +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/04_facts.sql +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/bmsdna/devtools/usage/sql/05_views.sql +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/justfile +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/fake_github.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_agent_mentions.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_api_usage.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_azure_arm.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_cli_version.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_env_files.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_find_injection.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_github_api.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_issue_do.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_issue_for_me.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_issue_show.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_issue_watch.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_lint_sql.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_lint_sql_files.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_logs.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_build_create_options.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_choice.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_create_args.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_description_not_truncated.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_translate.py +0 -0
- {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.46.0}/tests/test_usage.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
565
|
-
|
|
566
|
-
|
|
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.
|
|
569
|
-
f"{_base_url(remote)}/_apis/git/repositories/{quote(remote.repo, safe='')}/
|
|
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
|
-
|
|
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.
|
|
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
|