bmsdna-devtools 0.7.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.
Files changed (42) hide show
  1. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/PKG-INFO +1 -1
  2. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/ado_issue.py +100 -0
  3. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/cli.py +41 -0
  4. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/gh_issue.py +30 -0
  5. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/pyproject.toml +1 -1
  6. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/tests/test_ado_issue.py +73 -1
  7. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/tests/test_ado_issue_flow.py +60 -1
  8. bmsdna_devtools-0.8.0/tests/test_gh_issue.py +72 -0
  9. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/uv.lock +1 -1
  10. bmsdna_devtools-0.7.0/tests/test_gh_issue.py +0 -24
  11. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/.github/workflows/auto-release.yml +0 -0
  12. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/.github/workflows/python-publish.yml +0 -0
  13. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/.github/workflows/python-test.yml +0 -0
  14. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/.gitignore +0 -0
  15. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/.python-version +0 -0
  16. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/README.md +0 -0
  17. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/__init__.py +0 -0
  18. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/ado_auth.py +0 -0
  19. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/app_service_logs.py +0 -0
  20. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/bdt_config.py +0 -0
  21. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/cli_tools.py +0 -0
  22. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/commit.py +0 -0
  23. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/env_config.py +0 -0
  24. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/gh_pr.py +0 -0
  25. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/gitrepo.py +0 -0
  26. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/logs.py +0 -0
  27. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/pr_build.py +0 -0
  28. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/pr_labels.py +0 -0
  29. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/pr_markdown.py +0 -0
  30. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/bmsdna/devtools/worktree.py +0 -0
  31. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/justfile +0 -0
  32. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  33. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/tests/test_app_service_logs.py +0 -0
  34. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/tests/test_bdt_config.py +0 -0
  35. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/tests/test_cli_pr_create.py +0 -0
  36. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/tests/test_commit.py +0 -0
  37. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/tests/test_env_config.py +0 -0
  38. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/tests/test_gh_pr.py +0 -0
  39. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/tests/test_gitrepo.py +0 -0
  40. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/tests/test_pr_build.py +0 -0
  41. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/tests/test_pr_labels.py +0 -0
  42. {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.8.0}/tests/test_pr_markdown.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bmsdna-devtools
3
- Version: 0.7.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
@@ -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,
@@ -4,6 +4,7 @@ import json
4
4
  import subprocess
5
5
  import sys
6
6
  from collections.abc import Callable
7
+ from datetime import datetime, timedelta, timezone
7
8
  from importlib.metadata import version as _pkg_version
8
9
  from pathlib import Path
9
10
 
@@ -325,6 +326,46 @@ def issue_create(
325
326
  ado_issue.create(session, remote, type_, title, description, resolved_board, tag, screenshot)
326
327
 
327
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
+
328
369
  @issue_app.command("update")
329
370
  def issue_update(
330
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,
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.7.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 build_attach_ops, build_create_ops, build_update_ops, html_url, resolve_board
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,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"
@@ -13,7 +13,7 @@ wheels = [
13
13
 
14
14
  [[package]]
15
15
  name = "bmsdna-devtools"
16
- version = "0.7.0"
16
+ version = "0.8.0"
17
17
  source = { editable = "." }
18
18
  dependencies = [
19
19
  { name = "requests" },
@@ -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