bmsdna-devtools 0.6.0__tar.gz → 0.8.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.8.0/.github/workflows/auto-release.yml +83 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/PKG-INFO +48 -1
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/README.md +47 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/ado_issue.py +100 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/cli.py +84 -3
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/gh_issue.py +30 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/gh_pr.py +20 -3
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/pr_build.py +7 -0
- bmsdna_devtools-0.8.0/bmsdna/devtools/pr_labels.py +43 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/tests/test_ado_issue.py +73 -1
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/tests/test_ado_issue_flow.py +60 -1
- bmsdna_devtools-0.8.0/tests/test_cli_pr_create.py +132 -0
- bmsdna_devtools-0.8.0/tests/test_gh_issue.py +72 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/tests/test_gh_pr.py +33 -1
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/tests/test_pr_build.py +7 -1
- bmsdna_devtools-0.8.0/tests/test_pr_labels.py +56 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/uv.lock +1 -1
- bmsdna_devtools-0.6.0/tests/test_cli_pr_create.py +0 -43
- bmsdna_devtools-0.6.0/tests/test_gh_issue.py +0 -24
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/.gitignore +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/.python-version +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/justfile +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.6.0 → bmsdna_devtools-0.8.0}/tests/test_pr_markdown.py +0 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Auto-tags, cuts a GitHub Release, and dispatches a PyPI publish whenever
|
|
2
|
+
# pyproject.toml's version changes on main and the Python Test workflow has
|
|
3
|
+
# passed for that commit. No manual "cut a release" step needed.
|
|
4
|
+
#
|
|
5
|
+
# Two GitHub Actions quirks shaped this design -- both hit and confirmed the
|
|
6
|
+
# hard way, so read before "simplifying" this:
|
|
7
|
+
#
|
|
8
|
+
# 1. GitHub Actions doesn't fire other workflows' triggers (release:
|
|
9
|
+
# published included) for events performed with the automatic
|
|
10
|
+
# GITHUB_TOKEN, to prevent recursive runs. So creating the release here
|
|
11
|
+
# would NOT, on its own, trigger python-publish.yml's `release: published`
|
|
12
|
+
# listener. workflow_dispatch is the documented exception to that rule --
|
|
13
|
+
# an API-triggered workflow_dispatch DOES start a new run even when
|
|
14
|
+
# triggered by GITHUB_TOKEN -- so this dispatches python-publish.yml
|
|
15
|
+
# directly instead of relying on the release event to cascade.
|
|
16
|
+
#
|
|
17
|
+
# 2. The obvious alternative -- calling python-publish.yml's job directly via
|
|
18
|
+
# workflow_call instead of dispatching it as a separate run -- avoids (1)
|
|
19
|
+
# entirely, but breaks PyPI's OIDC trusted publishing: PyPI explicitly
|
|
20
|
+
# does not support trusted publishing from reusable/called workflows (the
|
|
21
|
+
# token's claims show a "reusable workflow" parent chain that PyPI's
|
|
22
|
+
# trusted-publisher matching rejects). So python-publish.yml must stay a
|
|
23
|
+
# plain, directly-triggered top-level workflow -- no workflow_call trigger
|
|
24
|
+
# on it, and nothing here should invoke it as `uses:`.
|
|
25
|
+
|
|
26
|
+
name: Auto Release
|
|
27
|
+
|
|
28
|
+
on:
|
|
29
|
+
workflow_run:
|
|
30
|
+
workflows: ["Python Test"]
|
|
31
|
+
types: [completed]
|
|
32
|
+
|
|
33
|
+
permissions:
|
|
34
|
+
contents: read
|
|
35
|
+
|
|
36
|
+
jobs:
|
|
37
|
+
release:
|
|
38
|
+
if: >
|
|
39
|
+
github.event.workflow_run.conclusion == 'success' &&
|
|
40
|
+
github.event.workflow_run.event == 'push' &&
|
|
41
|
+
github.event.workflow_run.head_branch == 'main'
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
permissions:
|
|
44
|
+
contents: write
|
|
45
|
+
actions: write
|
|
46
|
+
steps:
|
|
47
|
+
- uses: actions/checkout@v4
|
|
48
|
+
with:
|
|
49
|
+
ref: ${{ github.event.workflow_run.head_sha }}
|
|
50
|
+
|
|
51
|
+
- name: Read version from pyproject.toml
|
|
52
|
+
id: version
|
|
53
|
+
run: |
|
|
54
|
+
version=$(grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)"/\1/')
|
|
55
|
+
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
56
|
+
|
|
57
|
+
- name: Skip if this version is already on PyPI
|
|
58
|
+
id: check
|
|
59
|
+
run: |
|
|
60
|
+
version="${{ steps.version.outputs.version }}"
|
|
61
|
+
if curl -fsS https://pypi.org/pypi/bmsdna-devtools/json | jq -e --arg v "$version" '.releases[$v] != null' >/dev/null; then
|
|
62
|
+
echo "published=true" >> "$GITHUB_OUTPUT"
|
|
63
|
+
else
|
|
64
|
+
echo "published=false" >> "$GITHUB_OUTPUT"
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
- name: Create tag and GitHub release, if missing
|
|
68
|
+
if: steps.check.outputs.published == 'false'
|
|
69
|
+
env:
|
|
70
|
+
GH_TOKEN: ${{ github.token }}
|
|
71
|
+
run: |
|
|
72
|
+
if ! gh release view "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then
|
|
73
|
+
gh release create "v${{ steps.version.outputs.version }}" \
|
|
74
|
+
--title "v${{ steps.version.outputs.version }}" \
|
|
75
|
+
--target "${{ github.event.workflow_run.head_sha }}" \
|
|
76
|
+
--generate-notes
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
- name: Dispatch PyPI publish
|
|
80
|
+
if: steps.check.outputs.published == 'false'
|
|
81
|
+
env:
|
|
82
|
+
GH_TOKEN: ${{ github.token }}
|
|
83
|
+
run: gh workflow run python-publish.yml --ref main
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bmsdna-devtools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: requests>=2.32.0
|
|
@@ -78,6 +78,27 @@ title/body from commit info so it never blocks on an interactive prompt).
|
|
|
78
78
|
Extra arguments pass through either way, e.g.
|
|
79
79
|
`bdt pr create --target main -- --title "..."`.
|
|
80
80
|
|
|
81
|
+
`--label` (repeatable) applies labels to the PR on either host: on GitHub
|
|
82
|
+
these map to `gh pr create --label`, so the label must already exist on the
|
|
83
|
+
repo (`gh label create`); on Azure DevOps they map to `az repos pr create
|
|
84
|
+
--labels`, which are freeform and get created on the fly. A successful
|
|
85
|
+
create prints the PR's web/GUI link (not just the REST API URL Azure DevOps'
|
|
86
|
+
`az` output otherwise gives you).
|
|
87
|
+
|
|
88
|
+
`[tool.bdt.pr.required_labels]` in pyproject.toml can require at least one
|
|
89
|
+
label from each named group before the PR is created — checked locally
|
|
90
|
+
(no `gh`/`az` call happens if a group isn't satisfied):
|
|
91
|
+
|
|
92
|
+
```toml
|
|
93
|
+
[tool.bdt.pr.required_labels]
|
|
94
|
+
type = ["bug", "feature", "chore"]
|
|
95
|
+
risk = ["breaking", "non-breaking"]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
With the above, `bdt pr create --label feature --label breaking` passes,
|
|
99
|
+
but `bdt pr create --label feature` fails with a message naming the unmet
|
|
100
|
+
group (`risk`) and its allowed choices.
|
|
101
|
+
|
|
81
102
|
If `--target` has a build policy configured (an Azure DevOps Build policy,
|
|
82
103
|
or a GitHub branch protection rule requiring status checks), a successful
|
|
83
104
|
create prints a reminder to run `bdt pr status` afterward to check whether
|
|
@@ -222,3 +243,29 @@ slot = "test"
|
|
|
222
243
|
bdt logs fetch --env prod
|
|
223
244
|
bdt logs fetch --env prod --out logs/ --keep-archive
|
|
224
245
|
```
|
|
246
|
+
|
|
247
|
+
## Releasing
|
|
248
|
+
|
|
249
|
+
Bump `version` in `pyproject.toml` as part of your PR, same as any other
|
|
250
|
+
change. Once that PR merges to `main` and the `Python Test` workflow passes
|
|
251
|
+
for that commit, `.github/workflows/auto-release.yml` automatically tags it
|
|
252
|
+
`vX.Y.Z`, cuts a GitHub Release (skipping if that version was already
|
|
253
|
+
released, e.g. a merge that didn't touch the version), and dispatches
|
|
254
|
+
`python-publish.yml` to publish it to PyPI — no manual release step, and no
|
|
255
|
+
extra secret to configure. Two non-obvious GitHub Actions quirks shaped
|
|
256
|
+
this (see the comments at the top of `auto-release.yml` for the full
|
|
257
|
+
reasoning, since both were hit and confirmed the hard way):
|
|
258
|
+
|
|
259
|
+
- A release created with the default `GITHUB_TOKEN` does **not** trigger
|
|
260
|
+
other workflows' `release: published` listeners (an anti-recursion
|
|
261
|
+
safeguard) — `workflow_dispatch` is the documented exception, so
|
|
262
|
+
`auto-release.yml` dispatches `python-publish.yml` directly (`gh workflow
|
|
263
|
+
run`) instead of relying on the release to cascade into it.
|
|
264
|
+
- `python-publish.yml` deliberately stays a plain, directly-triggered
|
|
265
|
+
top-level workflow rather than something `auto-release.yml` calls via
|
|
266
|
+
`workflow_call`: PyPI's OIDC trusted publishing does not support
|
|
267
|
+
reusable/called workflows and silently rejects the token in that shape.
|
|
268
|
+
|
|
269
|
+
`workflow_dispatch` (or an actual GitHub UI release) on `python-publish.yml`
|
|
270
|
+
still works as a manual fallback if you ever need to re-publish a version
|
|
271
|
+
without going through `auto-release.yml`.
|
|
@@ -68,6 +68,27 @@ title/body from commit info so it never blocks on an interactive prompt).
|
|
|
68
68
|
Extra arguments pass through either way, e.g.
|
|
69
69
|
`bdt pr create --target main -- --title "..."`.
|
|
70
70
|
|
|
71
|
+
`--label` (repeatable) applies labels to the PR on either host: on GitHub
|
|
72
|
+
these map to `gh pr create --label`, so the label must already exist on the
|
|
73
|
+
repo (`gh label create`); on Azure DevOps they map to `az repos pr create
|
|
74
|
+
--labels`, which are freeform and get created on the fly. A successful
|
|
75
|
+
create prints the PR's web/GUI link (not just the REST API URL Azure DevOps'
|
|
76
|
+
`az` output otherwise gives you).
|
|
77
|
+
|
|
78
|
+
`[tool.bdt.pr.required_labels]` in pyproject.toml can require at least one
|
|
79
|
+
label from each named group before the PR is created — checked locally
|
|
80
|
+
(no `gh`/`az` call happens if a group isn't satisfied):
|
|
81
|
+
|
|
82
|
+
```toml
|
|
83
|
+
[tool.bdt.pr.required_labels]
|
|
84
|
+
type = ["bug", "feature", "chore"]
|
|
85
|
+
risk = ["breaking", "non-breaking"]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
With the above, `bdt pr create --label feature --label breaking` passes,
|
|
89
|
+
but `bdt pr create --label feature` fails with a message naming the unmet
|
|
90
|
+
group (`risk`) and its allowed choices.
|
|
91
|
+
|
|
71
92
|
If `--target` has a build policy configured (an Azure DevOps Build policy,
|
|
72
93
|
or a GitHub branch protection rule requiring status checks), a successful
|
|
73
94
|
create prints a reminder to run `bdt pr status` afterward to check whether
|
|
@@ -212,3 +233,29 @@ slot = "test"
|
|
|
212
233
|
bdt logs fetch --env prod
|
|
213
234
|
bdt logs fetch --env prod --out logs/ --keep-archive
|
|
214
235
|
```
|
|
236
|
+
|
|
237
|
+
## Releasing
|
|
238
|
+
|
|
239
|
+
Bump `version` in `pyproject.toml` as part of your PR, same as any other
|
|
240
|
+
change. Once that PR merges to `main` and the `Python Test` workflow passes
|
|
241
|
+
for that commit, `.github/workflows/auto-release.yml` automatically tags it
|
|
242
|
+
`vX.Y.Z`, cuts a GitHub Release (skipping if that version was already
|
|
243
|
+
released, e.g. a merge that didn't touch the version), and dispatches
|
|
244
|
+
`python-publish.yml` to publish it to PyPI — no manual release step, and no
|
|
245
|
+
extra secret to configure. Two non-obvious GitHub Actions quirks shaped
|
|
246
|
+
this (see the comments at the top of `auto-release.yml` for the full
|
|
247
|
+
reasoning, since both were hit and confirmed the hard way):
|
|
248
|
+
|
|
249
|
+
- A release created with the default `GITHUB_TOKEN` does **not** trigger
|
|
250
|
+
other workflows' `release: published` listeners (an anti-recursion
|
|
251
|
+
safeguard) — `workflow_dispatch` is the documented exception, so
|
|
252
|
+
`auto-release.yml` dispatches `python-publish.yml` directly (`gh workflow
|
|
253
|
+
run`) instead of relying on the release to cascade into it.
|
|
254
|
+
- `python-publish.yml` deliberately stays a plain, directly-triggered
|
|
255
|
+
top-level workflow rather than something `auto-release.yml` calls via
|
|
256
|
+
`workflow_call`: PyPI's OIDC trusted publishing does not support
|
|
257
|
+
reusable/called workflows and silently rejects the token in that shape.
|
|
258
|
+
|
|
259
|
+
`workflow_dispatch` (or an actual GitHub UI release) on `python-publish.yml`
|
|
260
|
+
still works as a manual fallback if you ever need to re-publish a version
|
|
261
|
+
without going through `auto-release.yml`.
|
|
@@ -264,6 +264,106 @@ def html_url(work_item: dict) -> str | None:
|
|
|
264
264
|
return work_item.get("_links", {}).get("html", {}).get("href")
|
|
265
265
|
|
|
266
266
|
|
|
267
|
+
def edit_url(remote: AdoRemote, work_item_id: int) -> str:
|
|
268
|
+
return f"{_base_url(remote)}/_workitems/edit/{work_item_id}"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _escape_wiql_string(value: str) -> str:
|
|
272
|
+
return value.replace("'", "''")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# The Completed/Removed-category state names across Azure DevOps's built-in process templates:
|
|
276
|
+
# Agile and CMMI use 'Closed' for Completed, Scrum and Basic use 'Done'; all four use 'Removed'.
|
|
277
|
+
# There's no single field/value that means "terminal" independent of process template, so this
|
|
278
|
+
# union is the closest thing to a default that works out of the box on any of them.
|
|
279
|
+
_TERMINAL_STATES = ("Closed", "Done", "Removed")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def build_search_wiql(
|
|
283
|
+
keywords: list[str],
|
|
284
|
+
since: str | None = None,
|
|
285
|
+
area_path: str | None = None,
|
|
286
|
+
state: str = "open",
|
|
287
|
+
) -> str:
|
|
288
|
+
"""WIQL for work items whose Title or Description contains every given keyword (ANDed),
|
|
289
|
+
optionally restricted to items changed on/after `since` (an ISO 'YYYY-MM-DD' date) and/or
|
|
290
|
+
scoped to a board's Area Path subtree, most recently changed first. `keywords` may be empty,
|
|
291
|
+
to list work items without a text filter.
|
|
292
|
+
|
|
293
|
+
`since` is rendered as a UTC ISO 8601 literal (`'YYYY-MM-DDT00:00:00Z'`) — the one
|
|
294
|
+
DateTime format WIQL accepts regardless of the querying account's locale/date-pattern
|
|
295
|
+
preference (a bare `'YYYY-MM-DD'` is parsed using that locale's date pattern instead).
|
|
296
|
+
|
|
297
|
+
`state` is `"open"` (default, excludes `_TERMINAL_STATES`), `"closed"` (only those), or
|
|
298
|
+
`"all"` (no state filter). Azure DevOps state names are process-template-specific — Agile and
|
|
299
|
+
CMMI use 'Closed' for their Completed-category state, Scrum and Basic use 'Done' — so
|
|
300
|
+
`_TERMINAL_STATES` is the union across the built-in templates, not a per-project source of
|
|
301
|
+
truth (a custom process with its own state names won't be filtered correctly).
|
|
302
|
+
"""
|
|
303
|
+
clauses = [
|
|
304
|
+
f"([System.Title] Contains Words '{_escape_wiql_string(k)}' OR [System.Description] Contains Words '{_escape_wiql_string(k)}')"
|
|
305
|
+
for k in keywords
|
|
306
|
+
]
|
|
307
|
+
if since:
|
|
308
|
+
clauses.append(f"[System.ChangedDate] >= '{since}T00:00:00Z'")
|
|
309
|
+
if area_path:
|
|
310
|
+
clauses.append(f"[System.AreaPath] UNDER '{_escape_wiql_string(area_path)}'")
|
|
311
|
+
if state == "open":
|
|
312
|
+
clauses.append(" AND ".join(f"[System.State] <> '{s}'" for s in _TERMINAL_STATES))
|
|
313
|
+
elif state == "closed":
|
|
314
|
+
clauses.append("(" + " OR ".join(f"[System.State] = '{s}'" for s in _TERMINAL_STATES) + ")")
|
|
315
|
+
where = " AND ".join(["[System.TeamProject] = @project", *clauses])
|
|
316
|
+
return f"SELECT [System.Id] FROM WorkItems WHERE {where} ORDER BY [System.ChangedDate] DESC"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int) -> list[int]:
|
|
320
|
+
r = session.post(
|
|
321
|
+
f"{_base_url(remote)}/_apis/wit/wiql",
|
|
322
|
+
params={"api-version": "7.1", "$top": top},
|
|
323
|
+
json={"query": wiql},
|
|
324
|
+
)
|
|
325
|
+
r.raise_for_status()
|
|
326
|
+
return [wi["id"] for wi in r.json()["workItems"]]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int]) -> list[dict]:
|
|
330
|
+
"""Batch-fetch Title/State for a set of work item ids. WIQL only returns ids, not field values."""
|
|
331
|
+
if not ids:
|
|
332
|
+
return []
|
|
333
|
+
r = session.get(
|
|
334
|
+
f"{_base_url(remote)}/_apis/wit/workitems",
|
|
335
|
+
params={"ids": ",".join(map(str, ids)), "fields": "System.Title,System.State", "api-version": "7.1"},
|
|
336
|
+
)
|
|
337
|
+
r.raise_for_status()
|
|
338
|
+
return r.json()["value"]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def search(
|
|
342
|
+
session: requests.Session,
|
|
343
|
+
remote: AdoRemote,
|
|
344
|
+
keywords: list[str],
|
|
345
|
+
since: str | None = None,
|
|
346
|
+
board: str | None = None,
|
|
347
|
+
top: int = 10,
|
|
348
|
+
state: str = "open",
|
|
349
|
+
) -> list[dict]:
|
|
350
|
+
"""Search (or, with no keywords, just list) work items by keywords (ANDed, matched against
|
|
351
|
+
Title or Description) and state, most recently changed first. `board` is an Azure Boards team
|
|
352
|
+
name (like `create`'s `--board`) — resolved to its Area Path so results are scoped to that
|
|
353
|
+
team's subtree instead of the whole project.
|
|
354
|
+
"""
|
|
355
|
+
area_path = get_team_area_path(session, remote, board) if board else None
|
|
356
|
+
ids = run_wiql(session, remote, build_search_wiql(keywords, since, area_path, state), top)
|
|
357
|
+
items = get_work_items(session, remote, ids)
|
|
358
|
+
for item in items:
|
|
359
|
+
fields = item["fields"]
|
|
360
|
+
print(f"#{item['id']} [{fields['System.State']}] {fields['System.Title']}")
|
|
361
|
+
print(edit_url(remote, item["id"]))
|
|
362
|
+
if not items:
|
|
363
|
+
print("No matching work items found.")
|
|
364
|
+
return items
|
|
365
|
+
|
|
366
|
+
|
|
267
367
|
def create(
|
|
268
368
|
session: requests.Session,
|
|
269
369
|
remote: AdoRemote,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import subprocess
|
|
4
5
|
import sys
|
|
5
6
|
from collections.abc import Callable
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
6
8
|
from importlib.metadata import version as _pkg_version
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
|
|
@@ -13,7 +15,7 @@ from . import ado_issue, app_service_logs, commit as commit_mod
|
|
|
13
15
|
from . import env_config
|
|
14
16
|
from . import gh_issue, gh_pr
|
|
15
17
|
from . import logs as logs_mod
|
|
16
|
-
from . import pr_build, worktree as worktree_mod
|
|
18
|
+
from . import pr_build, pr_labels, worktree as worktree_mod
|
|
17
19
|
from .ado_auth import auth_header
|
|
18
20
|
from .cli_tools import require_az, require_gh
|
|
19
21
|
from .gitrepo import AdoRemote, GitHubRemote, current_branch, current_remote
|
|
@@ -90,6 +92,14 @@ def _attach_screenshots(attach: Callable[[], None]) -> None:
|
|
|
90
92
|
def pr_create(
|
|
91
93
|
target: str = typer.Option("main", "--target", help="Target branch (e.g. main, test)"),
|
|
92
94
|
draft: bool = typer.Option(False, "--draft", help="Create the PR as a draft (not ready for review)"),
|
|
95
|
+
label: list[str] = typer.Option(
|
|
96
|
+
[],
|
|
97
|
+
"--label",
|
|
98
|
+
help="Label to apply to the PR (repeatable). On GitHub the label must already exist on the repo "
|
|
99
|
+
"(`gh label create`); Azure DevOps PR labels are freeform and created on the fly. "
|
|
100
|
+
r"[tool.bdt.pr.required_labels] in pyproject.toml can require at least one label from each "
|
|
101
|
+
"named group before the PR is created.",
|
|
102
|
+
),
|
|
93
103
|
screenshot: list[str] = typer.Option(
|
|
94
104
|
[], "--screenshot", help="Path to an image to attach to the PR description (repeatable)"
|
|
95
105
|
),
|
|
@@ -106,11 +116,16 @@ def pr_create(
|
|
|
106
116
|
if not Path(path).is_file():
|
|
107
117
|
raise typer.BadParameter(f"Screenshot not found: {path}", param_hint="--screenshot")
|
|
108
118
|
|
|
119
|
+
missing_groups = pr_labels.missing_label_groups(pr_labels.required_label_groups(), label)
|
|
120
|
+
if missing_groups:
|
|
121
|
+
raise typer.BadParameter(pr_labels.format_missing_groups_error(missing_groups), param_hint="--label")
|
|
122
|
+
|
|
109
123
|
remote = current_remote()
|
|
110
124
|
source_branch = current_branch()
|
|
125
|
+
pr_url: str | None = None
|
|
111
126
|
if isinstance(remote, GitHubRemote):
|
|
112
127
|
gh = require_gh()
|
|
113
|
-
returncode = gh_pr.create(gh, target, args or [], draft=draft)
|
|
128
|
+
returncode, pr_url = gh_pr.create(gh, target, args or [], draft=draft, labels=label)
|
|
114
129
|
build_policy = gh_pr.has_build_policy(gh, target)
|
|
115
130
|
if returncode == 0 and screenshot:
|
|
116
131
|
_attach_screenshots(lambda: gh_pr.add_screenshots(gh, remote.owner, remote.repo, source_branch, screenshot))
|
|
@@ -125,10 +140,33 @@ def pr_create(
|
|
|
125
140
|
"--target-branch", target,
|
|
126
141
|
"--source-branch", source_branch,
|
|
127
142
|
"--auto-complete", "false",
|
|
143
|
+
"--output", "json",
|
|
128
144
|
*(["--draft", "true"] if draft else []),
|
|
145
|
+
*(["--labels", *label] if label else []),
|
|
129
146
|
*(args or []),
|
|
130
147
|
]
|
|
131
|
-
|
|
148
|
+
r = subprocess.run(cmd, capture_output=True, encoding="utf-8")
|
|
149
|
+
returncode = r.returncode
|
|
150
|
+
if isinstance(r.stderr, str) and r.stderr.strip():
|
|
151
|
+
print(r.stderr.strip(), file=sys.stderr)
|
|
152
|
+
|
|
153
|
+
pr_json = None
|
|
154
|
+
if returncode == 0 and isinstance(r.stdout, str):
|
|
155
|
+
try:
|
|
156
|
+
pr_json = json.loads(r.stdout)
|
|
157
|
+
except json.JSONDecodeError:
|
|
158
|
+
pr_json = None
|
|
159
|
+
|
|
160
|
+
if pr_json is not None:
|
|
161
|
+
# Concise summary instead of the raw `az` JSON blob — the web
|
|
162
|
+
# link printed below is the part a human actually needs.
|
|
163
|
+
print(f"PR #{pr_json.get('pullRequestId', '?')}: {pr_json.get('title', '?')}")
|
|
164
|
+
try:
|
|
165
|
+
pr_url = pr_build.pr_web_url(remote, pr_json["pullRequestId"])
|
|
166
|
+
except (KeyError, TypeError):
|
|
167
|
+
pr_url = None
|
|
168
|
+
elif isinstance(r.stdout, str) and r.stdout.strip():
|
|
169
|
+
print(r.stdout.rstrip())
|
|
132
170
|
session = requests.Session()
|
|
133
171
|
session.headers.update(auth_header(pat))
|
|
134
172
|
build_policy = pr_build.has_build_policy(session, remote, target)
|
|
@@ -140,6 +178,9 @@ def pr_create(
|
|
|
140
178
|
_attach_screenshots(_add)
|
|
141
179
|
publish_hint = "`az repos pr update --id <PR-ID> --draft false`"
|
|
142
180
|
|
|
181
|
+
if returncode == 0 and pr_url:
|
|
182
|
+
print(f"\n{pr_url}")
|
|
183
|
+
|
|
143
184
|
if returncode == 0 and draft:
|
|
144
185
|
print(f"\nCreated as a draft PR. Run `bdt pr publish` (or {publish_hint}) to mark it ready for review.")
|
|
145
186
|
|
|
@@ -285,6 +326,46 @@ def issue_create(
|
|
|
285
326
|
ado_issue.create(session, remote, type_, title, description, resolved_board, tag, screenshot)
|
|
286
327
|
|
|
287
328
|
|
|
329
|
+
@issue_app.command("search")
|
|
330
|
+
def issue_search(
|
|
331
|
+
keywords: list[str] = typer.Argument(
|
|
332
|
+
None, help="Keywords to search for (ANDed together); omit to just list issues/work items"
|
|
333
|
+
),
|
|
334
|
+
since_days: int = typer.Option(
|
|
335
|
+
30, "--since-days", help="Only include issues/work items updated within this many days (0 = no date filter)"
|
|
336
|
+
),
|
|
337
|
+
state: str = typer.Option("open", "--state", help="Filter by state: 'open', 'closed', or 'all'"),
|
|
338
|
+
board: str | None = typer.Option(
|
|
339
|
+
None,
|
|
340
|
+
"--board",
|
|
341
|
+
help="Scope the search to this Azure Boards team's Area Path subtree (Azure DevOps only; "
|
|
342
|
+
r"falls back to \[tool.bdt.ado].board in pyproject.toml, same as `issue create`)",
|
|
343
|
+
),
|
|
344
|
+
limit: int = typer.Option(10, "--limit", help="Max results to return"),
|
|
345
|
+
pat: str | None = typer.Option(
|
|
346
|
+
None,
|
|
347
|
+
"--pat",
|
|
348
|
+
envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"],
|
|
349
|
+
help="Azure DevOps PAT (else falls back to `az` login)",
|
|
350
|
+
),
|
|
351
|
+
) -> None:
|
|
352
|
+
"""Search (or, with no keywords, just list) issues / work items, defaulting to open issues
|
|
353
|
+
from the last 30 days (Azure DevOps or GitHub, auto-detected).
|
|
354
|
+
"""
|
|
355
|
+
if state not in ("open", "closed", "all"):
|
|
356
|
+
raise typer.BadParameter("Must be one of: open, closed, all", param_hint="--state")
|
|
357
|
+
since = (datetime.now(timezone.utc) - timedelta(days=since_days)).strftime("%Y-%m-%d") if since_days > 0 else None
|
|
358
|
+
|
|
359
|
+
remote = current_remote()
|
|
360
|
+
if isinstance(remote, GitHubRemote):
|
|
361
|
+
gh_issue.search(require_gh(), keywords or [], since, limit, state)
|
|
362
|
+
else:
|
|
363
|
+
session = requests.Session()
|
|
364
|
+
session.headers.update(auth_header(pat))
|
|
365
|
+
resolved_board = ado_issue.resolve_board(board)
|
|
366
|
+
ado_issue.search(session, remote, keywords or [], since=since, board=resolved_board, top=limit, state=state)
|
|
367
|
+
|
|
368
|
+
|
|
288
369
|
@issue_app.command("update")
|
|
289
370
|
def issue_update(
|
|
290
371
|
number: int = typer.Argument(..., help="Issue number (GitHub) or work item ID (Azure DevOps)"),
|
|
@@ -18,6 +18,7 @@ hosts).
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
+
import json
|
|
21
22
|
import re
|
|
22
23
|
import subprocess
|
|
23
24
|
import sys
|
|
@@ -81,6 +82,35 @@ def create(
|
|
|
81
82
|
print(f"Attached {len(screenshot_paths)} screenshot(s) to issue #{number}")
|
|
82
83
|
|
|
83
84
|
|
|
85
|
+
def build_search_query(keywords: list[str], since: str | None) -> str:
|
|
86
|
+
"""The `gh issue list --search` query string: keywords ANDed together (GitHub search's
|
|
87
|
+
implicit default), optionally scoped to issues updated on/after `since` (an ISO
|
|
88
|
+
'YYYY-MM-DD' date) via the `updated:` qualifier. Always sorted `updated:desc` — GitHub's
|
|
89
|
+
default search order is text-relevance, not recency, which `search()`'s ordering relies on.
|
|
90
|
+
`keywords` may be empty, to list issues without a text filter.
|
|
91
|
+
"""
|
|
92
|
+
parts = [*keywords, "sort:updated-desc"]
|
|
93
|
+
if since:
|
|
94
|
+
parts.append(f"updated:>={since}")
|
|
95
|
+
return " ".join(parts)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def search(gh: str, keywords: list[str], since: str | None, limit: int, state: str = "open") -> list[dict]:
|
|
99
|
+
"""Search (or, with no keywords, just list) issues by state, most recently updated first.
|
|
100
|
+
|
|
101
|
+
`state` is `gh issue list`'s own `open|closed|all` flag, not a search qualifier.
|
|
102
|
+
"""
|
|
103
|
+
query = build_search_query(keywords, since)
|
|
104
|
+
out = _run_gh(gh, ["issue", "list", "--search", query, "--state", state, "--limit", str(limit), "--json", "number,title,url,state"])
|
|
105
|
+
items = json.loads(out) if out else []
|
|
106
|
+
for item in items:
|
|
107
|
+
print(f"#{item['number']} [{item['state']}] {item['title']}")
|
|
108
|
+
print(item["url"])
|
|
109
|
+
if not items:
|
|
110
|
+
print("No matching issues found.")
|
|
111
|
+
return items
|
|
112
|
+
|
|
113
|
+
|
|
84
114
|
def update(
|
|
85
115
|
gh: str,
|
|
86
116
|
number: int,
|
|
@@ -140,15 +140,32 @@ def run(gh: str, wait: bool) -> None:
|
|
|
140
140
|
return
|
|
141
141
|
|
|
142
142
|
|
|
143
|
-
def create(gh: str, target: str, extra_args: list[str], draft: bool = False) -> int:
|
|
143
|
+
def create(gh: str, target: str, extra_args: list[str], draft: bool = False, labels: list[str] | None = None) -> tuple[int, str | None]:
|
|
144
144
|
"""Create a GitHub PR from the current branch into `target`.
|
|
145
145
|
|
|
146
146
|
--fill autofills title/body from commit info so this never blocks on an
|
|
147
147
|
interactive prompt; pass --title/--body in extra_args to override (gh
|
|
148
148
|
lets explicit values take precedence over --fill).
|
|
149
|
+
|
|
150
|
+
`-l/--label` labels must already exist on the repo (`gh label create`) —
|
|
151
|
+
unlike Azure DevOps PR labels, GitHub rejects a label name it doesn't
|
|
152
|
+
already know about.
|
|
153
|
+
|
|
154
|
+
Returns (returncode, web_url) — on success `gh pr create` prints the
|
|
155
|
+
PR's web URL as its only stdout line, which is what a human needs to
|
|
156
|
+
open it; on failure the url is `None` and the CLI's stderr is
|
|
157
|
+
surfaced to ours.
|
|
149
158
|
"""
|
|
150
|
-
cmd = [gh, "pr", "create", "--base", target, "--fill", *(["--draft"] if draft else [])
|
|
151
|
-
|
|
159
|
+
cmd = [gh, "pr", "create", "--base", target, "--fill", *(["--draft"] if draft else [])]
|
|
160
|
+
for label in labels or []:
|
|
161
|
+
cmd += ["--label", label]
|
|
162
|
+
cmd += extra_args
|
|
163
|
+
r = subprocess.run(cmd, capture_output=True, encoding="utf-8")
|
|
164
|
+
if r.stderr:
|
|
165
|
+
print(r.stderr.strip(), file=sys.stderr)
|
|
166
|
+
if r.returncode != 0:
|
|
167
|
+
return r.returncode, None
|
|
168
|
+
return r.returncode, r.stdout.strip() or None
|
|
152
169
|
|
|
153
170
|
|
|
154
171
|
def publish(gh: str) -> None:
|
|
@@ -30,6 +30,13 @@ def _base_url(remote: AdoRemote) -> str:
|
|
|
30
30
|
return f"https://dev.azure.com/{remote.org}/{quote(remote.project, safe='')}"
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def pr_web_url(remote: AdoRemote, pr_id: int) -> str:
|
|
34
|
+
"""The browsable web page for a PR, as opposed to its REST API URL (which is all the
|
|
35
|
+
`az repos pr create` JSON response otherwise gives you).
|
|
36
|
+
"""
|
|
37
|
+
return f"{_base_url(remote)}/_git/{quote(remote.repo, safe='')}/pullrequest/{pr_id}"
|
|
38
|
+
|
|
39
|
+
|
|
33
40
|
# PolicyType.id for the built-in "Build" policy (branch policy requiring a
|
|
34
41
|
# build to pass) — same GUID across every ADO organization.
|
|
35
42
|
BUILD_POLICY_TYPE_ID = "0609b952-1397-4640-95ec-e00a01b2c241"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Required PR label groups, configured under `[tool.bdt.pr.required_labels]`
|
|
2
|
+
in pyproject.toml:
|
|
3
|
+
|
|
4
|
+
[tool.bdt.pr.required_labels]
|
|
5
|
+
type = ["bug", "feature", "chore"]
|
|
6
|
+
risk = ["breaking", "non-breaking"]
|
|
7
|
+
|
|
8
|
+
Each key names a group; its value is the labels that satisfy it. `bdt pr
|
|
9
|
+
create` must be given at least one label from every configured group
|
|
10
|
+
(checked locally, before talking to GitHub/Azure DevOps, so a PR missing a
|
|
11
|
+
required label is never created in the first place). Applies identically on
|
|
12
|
+
both hosts.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .bdt_config import load_bdt_table
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def required_label_groups(start: Path | None = None) -> dict[str, list[str]]:
|
|
23
|
+
"""`[tool.bdt.pr.required_labels]` -> {group name: [allowed label, ...]}."""
|
|
24
|
+
raw = load_bdt_table("pr", start).get("required_labels", {})
|
|
25
|
+
if not isinstance(raw, dict):
|
|
26
|
+
return {}
|
|
27
|
+
return {name: choices for name, choices in raw.items() if isinstance(choices, list)}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def missing_label_groups(groups: dict[str, list[str]], labels: list[str]) -> dict[str, list[str]]:
|
|
31
|
+
"""The subset of `groups` for which none of `labels` is a member.
|
|
32
|
+
|
|
33
|
+
Matched case-insensitively — this is a local config check independent of
|
|
34
|
+
whatever casing rules GitHub/Azure DevOps apply to the label text that
|
|
35
|
+
actually gets sent through unchanged.
|
|
36
|
+
"""
|
|
37
|
+
given = {label.lower() for label in labels}
|
|
38
|
+
return {name: choices for name, choices in groups.items() if given.isdisjoint(choice.lower() for choice in choices)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_missing_groups_error(missing: dict[str, list[str]]) -> str:
|
|
42
|
+
parts = [f"'{name}' (choose one of: {', '.join(choices)})" for name, choices in missing.items()]
|
|
43
|
+
return "Missing required PR label(s) for group " + "; group ".join(parts)
|
|
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "bmsdna-devtools"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.8.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.11"
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
from bmsdna.devtools.ado_issue import
|
|
1
|
+
from bmsdna.devtools.ado_issue import (
|
|
2
|
+
build_attach_ops,
|
|
3
|
+
build_create_ops,
|
|
4
|
+
build_search_wiql,
|
|
5
|
+
build_update_ops,
|
|
6
|
+
edit_url,
|
|
7
|
+
html_url,
|
|
8
|
+
resolve_board,
|
|
9
|
+
)
|
|
10
|
+
from bmsdna.devtools.gitrepo import AdoRemote
|
|
2
11
|
|
|
3
12
|
|
|
4
13
|
def write_pyproject(tmp_path, body: str):
|
|
@@ -91,3 +100,66 @@ def test_html_url_present() -> None:
|
|
|
91
100
|
|
|
92
101
|
def test_html_url_missing() -> None:
|
|
93
102
|
assert html_url({}) is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_edit_url() -> None:
|
|
106
|
+
assert edit_url(AdoRemote(org="myorg", project="MyProj", repo="myrepo"), 42) == "https://dev.azure.com/myorg/MyProj/_workitems/edit/42"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_build_search_wiql_keywords_only() -> None:
|
|
110
|
+
wiql = build_search_wiql(["auth"], state="all")
|
|
111
|
+
assert "[System.TeamProject] = @project" in wiql
|
|
112
|
+
assert "[System.Title] Contains Words 'auth'" in wiql
|
|
113
|
+
assert "[System.Description] Contains Words 'auth'" in wiql
|
|
114
|
+
assert "[System.ChangedDate] >=" not in wiql
|
|
115
|
+
assert wiql.endswith("ORDER BY [System.ChangedDate] DESC")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_build_search_wiql_no_keywords_lists_without_text_filter() -> None:
|
|
119
|
+
wiql = build_search_wiql([], state="all")
|
|
120
|
+
assert wiql == "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project ORDER BY [System.ChangedDate] DESC"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_build_search_wiql_defaults_to_open_state() -> None:
|
|
124
|
+
# Covers all four built-in process templates' terminal state names (Agile/CMMI: 'Closed',
|
|
125
|
+
# Scrum/Basic: 'Done'; all: 'Removed') since search() doesn't know which one a project uses.
|
|
126
|
+
wiql = build_search_wiql(["auth"])
|
|
127
|
+
assert "[System.State] <> 'Closed'" in wiql
|
|
128
|
+
assert "[System.State] <> 'Done'" in wiql
|
|
129
|
+
assert "[System.State] <> 'Removed'" in wiql
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_build_search_wiql_closed_state() -> None:
|
|
133
|
+
wiql = build_search_wiql(["auth"], state="closed")
|
|
134
|
+
assert "[System.State] = 'Closed'" in wiql
|
|
135
|
+
assert "[System.State] = 'Done'" in wiql
|
|
136
|
+
assert "[System.State] = 'Removed'" in wiql
|
|
137
|
+
assert "<>" not in wiql
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_build_search_wiql_all_state_has_no_state_clause() -> None:
|
|
141
|
+
assert "System.State" not in build_search_wiql(["auth"], state="all")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_build_search_wiql_multiple_keywords_are_anded() -> None:
|
|
145
|
+
wiql = build_search_wiql(["auth", "timeout"], state="all")
|
|
146
|
+
assert wiql.count(" AND ") == 2 # TeamProject AND keyword1 AND keyword2
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_build_search_wiql_since_adds_changed_date_clause() -> None:
|
|
150
|
+
wiql = build_search_wiql(["auth"], since="2026-08-10")
|
|
151
|
+
assert "[System.ChangedDate] >= '2026-08-10T00:00:00Z'" in wiql
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_build_search_wiql_escapes_single_quotes() -> None:
|
|
155
|
+
wiql = build_search_wiql(["it's broken"])
|
|
156
|
+
assert "it''s broken" in wiql
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_build_search_wiql_area_path_scopes_to_board() -> None:
|
|
160
|
+
wiql = build_search_wiql(["auth"], area_path="MyProj\\Data Team")
|
|
161
|
+
assert "[System.AreaPath] UNDER 'MyProj\\Data Team'" in wiql
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_build_search_wiql_no_area_path_by_default() -> None:
|
|
165
|
+
assert "AreaPath" not in build_search_wiql(["auth"])
|
|
@@ -13,6 +13,7 @@ from bmsdna.devtools.ado_issue import (
|
|
|
13
13
|
create,
|
|
14
14
|
delete,
|
|
15
15
|
delete_comment,
|
|
16
|
+
search,
|
|
16
17
|
update,
|
|
17
18
|
update_comment,
|
|
18
19
|
)
|
|
@@ -34,7 +35,7 @@ class FakeResponse:
|
|
|
34
35
|
raise RuntimeError(f"HTTP {self.status_code}")
|
|
35
36
|
|
|
36
37
|
|
|
37
|
-
def make_session(get_map: dict[str, dict] | None = None) -> MagicMock:
|
|
38
|
+
def make_session(get_map: dict[str, dict] | None = None, wiql_ids: list[int] | None = None) -> MagicMock:
|
|
38
39
|
session = MagicMock()
|
|
39
40
|
get_map = get_map or {}
|
|
40
41
|
|
|
@@ -45,6 +46,8 @@ def make_session(get_map: dict[str, dict] | None = None) -> MagicMock:
|
|
|
45
46
|
raise AssertionError(f"unexpected GET {url}")
|
|
46
47
|
|
|
47
48
|
def fake_post(url, params: dict | None = None, **kwargs):
|
|
49
|
+
if "/_apis/wit/wiql" in url:
|
|
50
|
+
return FakeResponse({"workItems": [{"id": i} for i in (wiql_ids or [])]})
|
|
48
51
|
if "/_apis/wit/attachments" in url:
|
|
49
52
|
assert params is not None
|
|
50
53
|
name = params["fileName"]
|
|
@@ -187,3 +190,59 @@ def test_delete_comment_hits_comment_id_endpoint() -> None:
|
|
|
187
190
|
|
|
188
191
|
url = session.delete.call_args.args[0]
|
|
189
192
|
assert url == "https://dev.azure.com/myorg/MyProj/_apis/wit/workItems/42/comments/7"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_search_runs_wiql_then_batch_fetches_matched_fields() -> None:
|
|
196
|
+
session = make_session(
|
|
197
|
+
get_map={"/_apis/wit/workitems": {"value": [{"id": 42, "fields": {"System.Title": "Auth timeout bug", "System.State": "Active"}}]}},
|
|
198
|
+
wiql_ids=[42],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
results = search(session, REMOTE, ["auth", "timeout"])
|
|
202
|
+
|
|
203
|
+
wiql_url, wiql_kwargs = session.post.call_args_list[0].args[0], session.post.call_args_list[0].kwargs
|
|
204
|
+
assert wiql_url == "https://dev.azure.com/myorg/MyProj/_apis/wit/wiql"
|
|
205
|
+
assert "auth" in wiql_kwargs["json"]["query"]
|
|
206
|
+
assert "timeout" in wiql_kwargs["json"]["query"]
|
|
207
|
+
|
|
208
|
+
get_url, get_kwargs = session.get.call_args.args[0], session.get.call_args.kwargs
|
|
209
|
+
assert get_url == "https://dev.azure.com/myorg/MyProj/_apis/wit/workitems"
|
|
210
|
+
assert get_kwargs["params"]["ids"] == "42"
|
|
211
|
+
|
|
212
|
+
assert results == [{"id": 42, "fields": {"System.Title": "Auth timeout bug", "System.State": "Active"}}]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_search_skips_batch_fetch_when_no_matches() -> None:
|
|
216
|
+
session = make_session(wiql_ids=[])
|
|
217
|
+
|
|
218
|
+
results = search(session, REMOTE, ["nonexistent-keyword"])
|
|
219
|
+
|
|
220
|
+
session.get.assert_not_called()
|
|
221
|
+
assert results == []
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_search_with_board_resolves_area_path_and_scopes_wiql() -> None:
|
|
225
|
+
session = make_session(
|
|
226
|
+
get_map={
|
|
227
|
+
"teamsettings/teamfieldvalues": {"defaultValue": "MyProj\\Data Team"},
|
|
228
|
+
"/_apis/wit/workitems": {"value": []},
|
|
229
|
+
},
|
|
230
|
+
wiql_ids=[],
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
search(session, REMOTE, ["auth"], board="Data Team")
|
|
234
|
+
|
|
235
|
+
# Team field values lookup must happen before the WIQL query is built/run.
|
|
236
|
+
get_url = session.get.call_args_list[0].args[0]
|
|
237
|
+
assert "myorg/MyProj/Data%20Team/_apis/work/teamsettings/teamfieldvalues" in get_url
|
|
238
|
+
|
|
239
|
+
wiql_kwargs = session.post.call_args.kwargs
|
|
240
|
+
assert "[System.AreaPath] UNDER 'MyProj\\Data Team'" in wiql_kwargs["json"]["query"]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_search_without_board_does_not_look_up_area_path() -> None:
|
|
244
|
+
session = make_session(wiql_ids=[])
|
|
245
|
+
|
|
246
|
+
search(session, REMOTE, ["auth"])
|
|
247
|
+
|
|
248
|
+
session.get.assert_not_called() # no --board given, so no team field values lookup at all
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""`bdt pr create` must pass the parsed org/project/repo through to
|
|
2
|
+
`az repos pr create` explicitly, rather than relying on `az`'s own remote
|
|
3
|
+
auto-detection -- that auto-detect doesn't understand the SSH `v3` remote
|
|
4
|
+
form at all, and separately, an `az devops configure -d project=...` default
|
|
5
|
+
that happens to point at a *different* ADO project than the current repo's
|
|
6
|
+
own silently makes `az` look for the PR's repository in the wrong project.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from unittest.mock import MagicMock
|
|
11
|
+
|
|
12
|
+
from typer.testing import CliRunner
|
|
13
|
+
|
|
14
|
+
from bmsdna.devtools.cli import app
|
|
15
|
+
from bmsdna.devtools.gitrepo import AdoRemote, GitHubRemote
|
|
16
|
+
|
|
17
|
+
runner = CliRunner()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_pr_create_passes_organization_project_repository_to_az(monkeypatch) -> None:
|
|
21
|
+
remote = AdoRemote("bmeurope", "BMS - CCMT2", "BMS - CCMT2")
|
|
22
|
+
monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: remote)
|
|
23
|
+
monkeypatch.setattr("bmsdna.devtools.cli.current_branch", lambda: "feature-x")
|
|
24
|
+
monkeypatch.setattr("bmsdna.devtools.cli.require_az", lambda: "az")
|
|
25
|
+
monkeypatch.setattr("bmsdna.devtools.cli.auth_header", lambda pat: {})
|
|
26
|
+
|
|
27
|
+
captured_cmd: list[str] = []
|
|
28
|
+
|
|
29
|
+
def fake_run(cmd, **kwargs):
|
|
30
|
+
captured_cmd[:] = cmd
|
|
31
|
+
return MagicMock(returncode=0)
|
|
32
|
+
|
|
33
|
+
monkeypatch.setattr("bmsdna.devtools.cli.subprocess.run", fake_run)
|
|
34
|
+
monkeypatch.setattr("bmsdna.devtools.pr_build.has_build_policy", lambda session, remote, target: False)
|
|
35
|
+
|
|
36
|
+
result = runner.invoke(app, ["pr", "create", "--target", "test"])
|
|
37
|
+
|
|
38
|
+
assert result.exit_code == 0, result.output
|
|
39
|
+
assert "--organization" in captured_cmd
|
|
40
|
+
assert captured_cmd[captured_cmd.index("--organization") + 1] == "https://dev.azure.com/bmeurope"
|
|
41
|
+
assert "--project" in captured_cmd
|
|
42
|
+
assert captured_cmd[captured_cmd.index("--project") + 1] == "BMS - CCMT2"
|
|
43
|
+
assert "--repository" in captured_cmd
|
|
44
|
+
assert captured_cmd[captured_cmd.index("--repository") + 1] == "BMS - CCMT2"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_pr_create_passes_labels_and_prints_web_link_for_ado(monkeypatch) -> None:
|
|
48
|
+
remote = AdoRemote("bmeurope", "BMS - CCMT2", "BMS - CCMT2")
|
|
49
|
+
monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: remote)
|
|
50
|
+
monkeypatch.setattr("bmsdna.devtools.cli.current_branch", lambda: "feature-x")
|
|
51
|
+
monkeypatch.setattr("bmsdna.devtools.cli.require_az", lambda: "az")
|
|
52
|
+
monkeypatch.setattr("bmsdna.devtools.cli.auth_header", lambda pat: {})
|
|
53
|
+
monkeypatch.setattr("bmsdna.devtools.pr_build.has_build_policy", lambda session, remote, target: False)
|
|
54
|
+
|
|
55
|
+
captured_cmd: list[str] = []
|
|
56
|
+
|
|
57
|
+
def fake_run(cmd, **kwargs):
|
|
58
|
+
captured_cmd[:] = cmd
|
|
59
|
+
return MagicMock(returncode=0, stdout=json.dumps({"pullRequestId": 456, "title": "feat: widgets"}), stderr="")
|
|
60
|
+
|
|
61
|
+
monkeypatch.setattr("bmsdna.devtools.cli.subprocess.run", fake_run)
|
|
62
|
+
|
|
63
|
+
result = runner.invoke(app, ["pr", "create", "--target", "test", "--label", "bug", "--label", "urgent"])
|
|
64
|
+
|
|
65
|
+
assert result.exit_code == 0, result.output
|
|
66
|
+
assert "--labels" in captured_cmd
|
|
67
|
+
labels_idx = captured_cmd.index("--labels")
|
|
68
|
+
assert captured_cmd[labels_idx + 1 : labels_idx + 3] == ["bug", "urgent"]
|
|
69
|
+
assert "https://dev.azure.com/bmeurope/BMS%20-%20CCMT2/_git/BMS%20-%20CCMT2/pullrequest/456" in result.output
|
|
70
|
+
assert "PR #456: feat: widgets" in result.output
|
|
71
|
+
assert '"pullRequestId"' not in result.output # the raw `az` JSON blob must not be dumped on success
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_pr_create_passes_labels_and_prints_web_link_for_github(monkeypatch) -> None:
|
|
75
|
+
remote = GitHubRemote("owner", "repo")
|
|
76
|
+
monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: remote)
|
|
77
|
+
monkeypatch.setattr("bmsdna.devtools.cli.current_branch", lambda: "feature-x")
|
|
78
|
+
monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh")
|
|
79
|
+
monkeypatch.setattr("bmsdna.devtools.gh_pr.has_build_policy", lambda gh, target: False)
|
|
80
|
+
|
|
81
|
+
captured_cmd: list[str] = []
|
|
82
|
+
|
|
83
|
+
def fake_run(cmd, **kwargs):
|
|
84
|
+
captured_cmd[:] = cmd
|
|
85
|
+
return MagicMock(returncode=0, stdout="https://github.com/owner/repo/pull/7\n", stderr="")
|
|
86
|
+
|
|
87
|
+
monkeypatch.setattr("bmsdna.devtools.gh_pr.subprocess.run", fake_run)
|
|
88
|
+
|
|
89
|
+
result = runner.invoke(app, ["pr", "create", "--target", "main", "--label", "bug"])
|
|
90
|
+
|
|
91
|
+
assert result.exit_code == 0, result.output
|
|
92
|
+
assert captured_cmd.count("--label") == 1
|
|
93
|
+
assert captured_cmd[captured_cmd.index("--label") + 1] == "bug"
|
|
94
|
+
assert "https://github.com/owner/repo/pull/7" in result.output
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_pr_create_fails_before_touching_gh_az_when_required_label_group_missing(monkeypatch) -> None:
|
|
98
|
+
monkeypatch.setattr(
|
|
99
|
+
"bmsdna.devtools.cli.pr_labels.required_label_groups",
|
|
100
|
+
lambda: {"risk": ["breaking", "non-breaking"]},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def fail_if_called(*args, **kwargs):
|
|
104
|
+
raise AssertionError("should not be reached — the missing-label check must run first")
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr("bmsdna.devtools.cli.current_remote", fail_if_called)
|
|
107
|
+
|
|
108
|
+
result = runner.invoke(app, ["pr", "create", "--target", "main", "--label", "bug"])
|
|
109
|
+
|
|
110
|
+
assert result.exit_code != 0
|
|
111
|
+
assert "risk" in result.output
|
|
112
|
+
assert "breaking" in result.output
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_pr_create_proceeds_when_required_label_group_satisfied(monkeypatch) -> None:
|
|
116
|
+
remote = GitHubRemote("owner", "repo")
|
|
117
|
+
monkeypatch.setattr(
|
|
118
|
+
"bmsdna.devtools.cli.pr_labels.required_label_groups",
|
|
119
|
+
lambda: {"risk": ["breaking", "non-breaking"]},
|
|
120
|
+
)
|
|
121
|
+
monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: remote)
|
|
122
|
+
monkeypatch.setattr("bmsdna.devtools.cli.current_branch", lambda: "feature-x")
|
|
123
|
+
monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh")
|
|
124
|
+
monkeypatch.setattr("bmsdna.devtools.gh_pr.has_build_policy", lambda gh, target: False)
|
|
125
|
+
monkeypatch.setattr(
|
|
126
|
+
"bmsdna.devtools.gh_pr.subprocess.run",
|
|
127
|
+
lambda cmd, **kwargs: MagicMock(returncode=0, stdout="https://github.com/owner/repo/pull/7\n", stderr=""),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
result = runner.invoke(app, ["pr", "create", "--target", "main", "--label", "non-breaking"])
|
|
131
|
+
|
|
132
|
+
assert result.exit_code == 0, result.output
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from bmsdna.devtools.gh_issue import build_search_query, parse_comment_id, parse_issue_number, search
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.parametrize(
|
|
9
|
+
"url,expected",
|
|
10
|
+
[
|
|
11
|
+
("https://github.com/owner/repo/issues/42", "42"),
|
|
12
|
+
("https://github.com/owner/repo/issues/42/", "42"),
|
|
13
|
+
("http://github.com/owner/repo/issues/1", "1"),
|
|
14
|
+
],
|
|
15
|
+
)
|
|
16
|
+
def test_parse_issue_number(url: str, expected: str) -> None:
|
|
17
|
+
assert parse_issue_number(url) == expected
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_parse_comment_id_present() -> None:
|
|
21
|
+
url = "https://github.com/owner/repo/issues/42#issuecomment-123456789"
|
|
22
|
+
assert parse_comment_id(url) == "123456789"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_parse_comment_id_absent() -> None:
|
|
26
|
+
assert parse_comment_id("https://github.com/owner/repo/issues/42") is None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_build_search_query_keywords_only() -> None:
|
|
30
|
+
assert build_search_query(["auth", "timeout"], None) == "auth timeout sort:updated-desc"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_build_search_query_adds_updated_qualifier() -> None:
|
|
34
|
+
assert build_search_query(["auth"], "2026-08-10") == "auth sort:updated-desc updated:>=2026-08-10"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_build_search_query_no_keywords_still_scopes_by_date() -> None:
|
|
38
|
+
assert build_search_query([], "2026-08-10") == "sort:updated-desc updated:>=2026-08-10"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_build_search_query_always_sorts_by_updated_desc() -> None:
|
|
42
|
+
# search()'s "most recently updated first" ordering depends on this — GitHub's
|
|
43
|
+
# default --search order is text relevance, not recency.
|
|
44
|
+
assert "sort:updated-desc" in build_search_query(["auth"], None)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_search_defaults_to_open_state(monkeypatch) -> None:
|
|
48
|
+
captured_cmd: list[str] = []
|
|
49
|
+
|
|
50
|
+
def fake_run(cmd, **kwargs):
|
|
51
|
+
captured_cmd[:] = cmd
|
|
52
|
+
return MagicMock(returncode=0, stdout="[]", stderr="")
|
|
53
|
+
|
|
54
|
+
monkeypatch.setattr("bmsdna.devtools.gh_issue.subprocess.run", fake_run)
|
|
55
|
+
|
|
56
|
+
search("gh", [], None, 10)
|
|
57
|
+
|
|
58
|
+
assert captured_cmd[captured_cmd.index("--state") + 1] == "open"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_search_passes_through_requested_state(monkeypatch) -> None:
|
|
62
|
+
captured_cmd: list[str] = []
|
|
63
|
+
|
|
64
|
+
def fake_run(cmd, **kwargs):
|
|
65
|
+
captured_cmd[:] = cmd
|
|
66
|
+
return MagicMock(returncode=0, stdout="[]", stderr="")
|
|
67
|
+
|
|
68
|
+
monkeypatch.setattr("bmsdna.devtools.gh_issue.subprocess.run", fake_run)
|
|
69
|
+
|
|
70
|
+
search("gh", ["auth"], None, 10, state="all")
|
|
71
|
+
|
|
72
|
+
assert captured_cmd[captured_cmd.index("--state") + 1] == "all"
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
|
|
1
3
|
import pytest
|
|
2
4
|
|
|
3
|
-
from bmsdna.devtools.gh_pr import check_bucket, check_label, merge_conflict_message, protection_requires_status_checks
|
|
5
|
+
from bmsdna.devtools.gh_pr import check_bucket, check_label, create, merge_conflict_message, protection_requires_status_checks
|
|
4
6
|
|
|
5
7
|
# Real statusCheckRollup entries captured from `gh pr view 13902 -R cli/cli --json statusCheckRollup`.
|
|
6
8
|
COMPLETED_SUCCESS_CHECK_RUN = {
|
|
@@ -70,3 +72,33 @@ def test_protection_requires_status_checks_true_when_configured() -> None:
|
|
|
70
72
|
@pytest.mark.parametrize("protection", [{}, {"required_status_checks": None}])
|
|
71
73
|
def test_protection_requires_status_checks_false_when_absent(protection: dict) -> None:
|
|
72
74
|
assert protection_requires_status_checks(protection) is False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_create_passes_repeatable_label_flags(monkeypatch) -> None:
|
|
78
|
+
captured_cmd: list[str] = []
|
|
79
|
+
|
|
80
|
+
def fake_run(cmd, **kwargs):
|
|
81
|
+
captured_cmd[:] = cmd
|
|
82
|
+
return MagicMock(returncode=0, stdout="https://github.com/owner/repo/pull/7\n", stderr="")
|
|
83
|
+
|
|
84
|
+
monkeypatch.setattr("bmsdna.devtools.gh_pr.subprocess.run", fake_run)
|
|
85
|
+
|
|
86
|
+
returncode, url = create("gh", "main", [], labels=["bug", "urgent"])
|
|
87
|
+
|
|
88
|
+
assert returncode == 0
|
|
89
|
+
assert url == "https://github.com/owner/repo/pull/7"
|
|
90
|
+
assert captured_cmd.count("--label") == 2
|
|
91
|
+
assert captured_cmd[captured_cmd.index("--label") + 1] == "bug"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_create_returns_none_url_on_failure(monkeypatch, capsys) -> None:
|
|
95
|
+
def fake_run(cmd, **kwargs):
|
|
96
|
+
return MagicMock(returncode=1, stdout="", stderr="label 'nope' not found")
|
|
97
|
+
|
|
98
|
+
monkeypatch.setattr("bmsdna.devtools.gh_pr.subprocess.run", fake_run)
|
|
99
|
+
|
|
100
|
+
returncode, url = create("gh", "main", [], labels=["nope"])
|
|
101
|
+
|
|
102
|
+
assert returncode == 1
|
|
103
|
+
assert url is None
|
|
104
|
+
assert "not found" in capsys.readouterr().err
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
|
-
from bmsdna.devtools.
|
|
3
|
+
from bmsdna.devtools.gitrepo import AdoRemote
|
|
4
|
+
from bmsdna.devtools.pr_build import merge_conflict_message, policy_configs_include_branch, pr_web_url
|
|
4
5
|
|
|
5
6
|
REPO_ID = "0cd3a822-389e-416e-a4fa-b73f988c2930"
|
|
6
7
|
|
|
@@ -95,3 +96,8 @@ def test_policy_configs_include_branch_matches_default_branch_scope() -> None:
|
|
|
95
96
|
}
|
|
96
97
|
assert policy_configs_include_branch([default_branch_policy], REPO_ID, "main", "refs/heads/main") is True
|
|
97
98
|
assert policy_configs_include_branch([default_branch_policy], REPO_ID, "test", "refs/heads/main") is False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_pr_web_url_is_the_browsable_page_not_the_rest_api_url() -> None:
|
|
102
|
+
remote = AdoRemote("bmeurope", "BMS - CCMT2", "BMS - CCMT2")
|
|
103
|
+
assert pr_web_url(remote, 123) == "https://dev.azure.com/bmeurope/BMS%20-%20CCMT2/_git/BMS%20-%20CCMT2/pullrequest/123"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from bmsdna.devtools.pr_labels import format_missing_groups_error, missing_label_groups, required_label_groups
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def write_pyproject(tmp_path, body: str):
|
|
5
|
+
(tmp_path / "pyproject.toml").write_text(body)
|
|
6
|
+
return tmp_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_required_label_groups_empty_when_no_pyproject(tmp_path) -> None:
|
|
10
|
+
assert required_label_groups(tmp_path) == {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_required_label_groups_empty_when_table_absent(tmp_path) -> None:
|
|
14
|
+
write_pyproject(tmp_path, "[tool.bdt.ado]\nboard = 'x'\n")
|
|
15
|
+
assert required_label_groups(tmp_path) == {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_required_label_groups_reads_named_groups(tmp_path) -> None:
|
|
19
|
+
write_pyproject(
|
|
20
|
+
tmp_path,
|
|
21
|
+
"[tool.bdt.pr.required_labels]\n"
|
|
22
|
+
'type = ["bug", "feature", "chore"]\n'
|
|
23
|
+
'risk = ["breaking", "non-breaking"]\n',
|
|
24
|
+
)
|
|
25
|
+
assert required_label_groups(tmp_path) == {
|
|
26
|
+
"type": ["bug", "feature", "chore"],
|
|
27
|
+
"risk": ["breaking", "non-breaking"],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_missing_label_groups_none_missing_when_all_satisfied() -> None:
|
|
32
|
+
groups = {"type": ["bug", "feature"], "risk": ["breaking", "non-breaking"]}
|
|
33
|
+
assert missing_label_groups(groups, ["feature", "breaking"]) == {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_missing_label_groups_reports_unsatisfied_group() -> None:
|
|
37
|
+
groups = {"type": ["bug", "feature"], "risk": ["breaking", "non-breaking"]}
|
|
38
|
+
assert missing_label_groups(groups, ["feature"]) == {"risk": ["breaking", "non-breaking"]}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_missing_label_groups_matches_case_insensitively() -> None:
|
|
42
|
+
groups = {"risk": ["breaking", "non-breaking"]}
|
|
43
|
+
assert missing_label_groups(groups, ["Breaking"]) == {}
|
|
44
|
+
assert missing_label_groups(groups, ["BREAKING"]) == {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_missing_label_groups_reports_all_when_no_labels_given() -> None:
|
|
48
|
+
groups = {"type": ["bug", "feature"], "risk": ["breaking", "non-breaking"]}
|
|
49
|
+
assert missing_label_groups(groups, []) == groups
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_format_missing_groups_error_lists_group_name_and_choices() -> None:
|
|
53
|
+
msg = format_missing_groups_error({"risk": ["breaking", "non-breaking"]})
|
|
54
|
+
assert "risk" in msg
|
|
55
|
+
assert "breaking" in msg
|
|
56
|
+
assert "non-breaking" in msg
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
"""`bdt pr create` must pass the parsed org/project/repo through to
|
|
2
|
-
`az repos pr create` explicitly, rather than relying on `az`'s own remote
|
|
3
|
-
auto-detection -- that auto-detect doesn't understand the SSH `v3` remote
|
|
4
|
-
form at all, and separately, an `az devops configure -d project=...` default
|
|
5
|
-
that happens to point at a *different* ADO project than the current repo's
|
|
6
|
-
own silently makes `az` look for the PR's repository in the wrong project.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from unittest.mock import MagicMock
|
|
10
|
-
|
|
11
|
-
from typer.testing import CliRunner
|
|
12
|
-
|
|
13
|
-
from bmsdna.devtools.cli import app
|
|
14
|
-
from bmsdna.devtools.gitrepo import AdoRemote
|
|
15
|
-
|
|
16
|
-
runner = CliRunner()
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def test_pr_create_passes_organization_project_repository_to_az(monkeypatch) -> None:
|
|
20
|
-
remote = AdoRemote("bmeurope", "BMS - CCMT2", "BMS - CCMT2")
|
|
21
|
-
monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: remote)
|
|
22
|
-
monkeypatch.setattr("bmsdna.devtools.cli.current_branch", lambda: "feature-x")
|
|
23
|
-
monkeypatch.setattr("bmsdna.devtools.cli.require_az", lambda: "az")
|
|
24
|
-
monkeypatch.setattr("bmsdna.devtools.cli.auth_header", lambda pat: {})
|
|
25
|
-
|
|
26
|
-
captured_cmd: list[str] = []
|
|
27
|
-
|
|
28
|
-
def fake_run(cmd, **kwargs):
|
|
29
|
-
captured_cmd[:] = cmd
|
|
30
|
-
return MagicMock(returncode=0)
|
|
31
|
-
|
|
32
|
-
monkeypatch.setattr("bmsdna.devtools.cli.subprocess.run", fake_run)
|
|
33
|
-
monkeypatch.setattr("bmsdna.devtools.pr_build.has_build_policy", lambda session, remote, target: False)
|
|
34
|
-
|
|
35
|
-
result = runner.invoke(app, ["pr", "create", "--target", "test"])
|
|
36
|
-
|
|
37
|
-
assert result.exit_code == 0, result.output
|
|
38
|
-
assert "--organization" in captured_cmd
|
|
39
|
-
assert captured_cmd[captured_cmd.index("--organization") + 1] == "https://dev.azure.com/bmeurope"
|
|
40
|
-
assert "--project" in captured_cmd
|
|
41
|
-
assert captured_cmd[captured_cmd.index("--project") + 1] == "BMS - CCMT2"
|
|
42
|
-
assert "--repository" in captured_cmd
|
|
43
|
-
assert captured_cmd[captured_cmd.index("--repository") + 1] == "BMS - CCMT2"
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
|
|
3
|
-
from bmsdna.devtools.gh_issue import parse_comment_id, parse_issue_number
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
@pytest.mark.parametrize(
|
|
7
|
-
"url,expected",
|
|
8
|
-
[
|
|
9
|
-
("https://github.com/owner/repo/issues/42", "42"),
|
|
10
|
-
("https://github.com/owner/repo/issues/42/", "42"),
|
|
11
|
-
("http://github.com/owner/repo/issues/1", "1"),
|
|
12
|
-
],
|
|
13
|
-
)
|
|
14
|
-
def test_parse_issue_number(url: str, expected: str) -> None:
|
|
15
|
-
assert parse_issue_number(url) == expected
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def test_parse_comment_id_present() -> None:
|
|
19
|
-
url = "https://github.com/owner/repo/issues/42#issuecomment-123456789"
|
|
20
|
-
assert parse_comment_id(url) == "123456789"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def test_parse_comment_id_absent() -> None:
|
|
24
|
-
assert parse_comment_id("https://github.com/owner/repo/issues/42") is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|