bmsdna-devtools 0.41.0__tar.gz → 0.42.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 (99) hide show
  1. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/PKG-INFO +8 -2
  2. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/README.md +7 -1
  3. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/cli.py +13 -3
  4. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/cli_tools.py +43 -0
  5. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/gh_pr.py +18 -3
  6. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_build.py +17 -3
  7. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/pyproject.toml +1 -1
  8. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/skills/bmsdna-devtools/SKILL.md +5 -1
  9. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_cli_pr_id.py +2 -2
  10. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_pr_choice.py +2 -2
  11. bmsdna_devtools-0.42.0/tests/test_wait_timeout.py +187 -0
  12. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/uv.lock +1 -1
  13. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/.github/workflows/auto-release.yml +0 -0
  14. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/.github/workflows/python-publish.yml +0 -0
  15. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/.github/workflows/python-test.yml +0 -0
  16. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/.gitignore +0 -0
  17. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/.python-version +0 -0
  18. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/__init__.py +0 -0
  19. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/_api_dump.py +0 -0
  20. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/ado_auth.py +0 -0
  21. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/ado_issue.py +0 -0
  22. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/agent_mentions.py +0 -0
  23. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/api_usage.py +0 -0
  24. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/app_service_logs.py +0 -0
  25. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/bdt_config.py +0 -0
  26. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/commit.py +0 -0
  27. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/dead_code.py +0 -0
  28. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/env_config.py +0 -0
  29. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/env_files.py +0 -0
  30. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/find_injection.py +0 -0
  31. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/find_repo.py +0 -0
  32. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/gh_issue.py +0 -0
  33. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/gitrepo.py +0 -0
  34. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/injection_frontend.py +0 -0
  35. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/injection_python.py +0 -0
  36. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/issue_do.py +0 -0
  37. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/issue_show.py +0 -0
  38. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/issue_watch.py +0 -0
  39. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint.py +0 -0
  40. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_findings.py +0 -0
  41. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_models.py +0 -0
  42. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_sql.py +0 -0
  43. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  44. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_tooling.py +0 -0
  45. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_typescript.py +0 -0
  46. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/logs.py +0 -0
  47. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_choice.py +0 -0
  48. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_info.py +0 -0
  49. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  50. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_labels.py +0 -0
  51. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_markdown.py +0 -0
  52. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pull.py +0 -0
  53. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/testdb.py +0 -0
  54. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/translate.py +0 -0
  55. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/worktree.py +0 -0
  56. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/justfile +0 -0
  57. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/conftest.py +0 -0
  58. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_ado_auth.py +0 -0
  59. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_ado_issue.py +0 -0
  60. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_ado_issue_flow.py +0 -0
  61. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_agent_mentions.py +0 -0
  62. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_api_usage.py +0 -0
  63. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_app_service_logs.py +0 -0
  64. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_bdt_config.py +0 -0
  65. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_cli_cleanup.py +0 -0
  66. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_cli_commit.py +0 -0
  67. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_cli_issue.py +0 -0
  68. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_cli_pr_create.py +0 -0
  69. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_cli_pull.py +0 -0
  70. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_cli_tools.py +0 -0
  71. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_commit.py +0 -0
  72. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_env_config.py +0 -0
  73. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_env_files.py +0 -0
  74. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_find_injection.py +0 -0
  75. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_find_repo.py +0 -0
  76. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_gh_issue.py +0 -0
  77. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_gh_pr.py +0 -0
  78. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_gitrepo.py +0 -0
  79. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_issue_do.py +0 -0
  80. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_issue_for_me.py +0 -0
  81. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_issue_show.py +0 -0
  82. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_issue_watch.py +0 -0
  83. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_lint.py +0 -0
  84. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_lint_models.py +0 -0
  85. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_lint_sql.py +0 -0
  86. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_lint_sql_files.py +0 -0
  87. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_lint_tooling.py +0 -0
  88. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_lint_typescript.py +0 -0
  89. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_pr_build.py +0 -0
  90. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_pr_build_flow.py +0 -0
  91. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_pr_description_not_truncated.py +0 -0
  92. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_pr_info.py +0 -0
  93. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_pr_issue_link.py +0 -0
  94. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_pr_labels.py +0 -0
  95. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_pr_markdown.py +0 -0
  96. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_pull.py +0 -0
  97. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_testdb.py +0 -0
  98. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_translate.py +0 -0
  99. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.42.0}/tests/test_worktree_clean.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bmsdna-devtools
3
- Version: 0.41.0
3
+ Version: 0.42.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: pgdevkit[db]>=0.7.1
@@ -50,7 +50,7 @@ Find the PR opened from the current branch and report build/check status
50
50
  GitHub — whichever `origin` points at.
51
51
 
52
52
  ```bash
53
- bdt pr status [--target-branch BRANCH] [--wait]
53
+ bdt pr status [--target-branch BRANCH] [--pr-id N] [--wait [--timeout SECONDS]]
54
54
  ```
55
55
 
56
56
  Every command that acts on an existing PR (`pr status/info/retry/publish/update/comment`, `issue take` without
@@ -75,6 +75,12 @@ stage/check needs a reviewer plus a link to act on it, and exits **3**
75
75
  build/check has already failed, that's reported instead (exit 1) even when
76
76
  one is also waiting on approval.
77
77
 
78
+ `--wait --timeout SECONDS` gives up after that long when the checks are still running: it prints the current
79
+ state of every pipeline/check, ends with `Still running after N s. Re-run ... to keep waiting.` (the command pins `--pr-id` to the PR it watched) and exits **4**
80
+ (like `issue watch`). Use it where the caller's own limit is shorter than CI, e.g. an agent's shell tool, which
81
+ kills the command after a few minutes and discards its output; re-run the same command to keep waiting.
82
+ Without `--timeout` (or with `0`) it waits until everything is done, as before. `--timeout` needs `--wait`.
83
+
78
84
  On Azure DevOps, `--wait` on a PR with no builds yet also checks that the target branch has an enabled
79
85
  Build validation policy; if it doesn't, no build will ever run, so it exits 1 with an error instead of
80
86
  polling forever (a failed policy lookup is ignored and the wait continues).
@@ -36,7 +36,7 @@ Find the PR opened from the current branch and report build/check status
36
36
  GitHub — whichever `origin` points at.
37
37
 
38
38
  ```bash
39
- bdt pr status [--target-branch BRANCH] [--wait]
39
+ bdt pr status [--target-branch BRANCH] [--pr-id N] [--wait [--timeout SECONDS]]
40
40
  ```
41
41
 
42
42
  Every command that acts on an existing PR (`pr status/info/retry/publish/update/comment`, `issue take` without
@@ -61,6 +61,12 @@ stage/check needs a reviewer plus a link to act on it, and exits **3**
61
61
  build/check has already failed, that's reported instead (exit 1) even when
62
62
  one is also waiting on approval.
63
63
 
64
+ `--wait --timeout SECONDS` gives up after that long when the checks are still running: it prints the current
65
+ state of every pipeline/check, ends with `Still running after N s. Re-run ... to keep waiting.` (the command pins `--pr-id` to the PR it watched) and exits **4**
66
+ (like `issue watch`). Use it where the caller's own limit is shorter than CI, e.g. an agent's shell tool, which
67
+ kills the command after a few minutes and discards its output; re-run the same command to keep waiting.
68
+ Without `--timeout` (or with `0`) it waits until everything is done, as before. `--timeout` needs `--wait`.
69
+
64
70
  On Azure DevOps, `--wait` on a PR with no builds yet also checks that the target branch has an enabled
65
71
  Build validation policy; if it doesn't, no build will ever run, so it exits 1 with an error instead of
66
72
  polling forever (a failed policy lookup is ignored and the wait continues).
@@ -34,7 +34,7 @@ from . import translate as translate_mod
34
34
  from . import pr_build, pr_info as pr_info_mod, pr_issue_link, pr_labels, pull as pull_mod, worktree as worktree_mod
35
35
  from .ado_auth import auth_header
36
36
  from .bdt_config import find_pyproject, load_bdt_table
37
- from .cli_tools import CLI_TIMEOUT_SECS, detect_agent_session, require_az, require_gh
37
+ from .cli_tools import CLI_TIMEOUT_SECS, EXIT_WAIT_TIMEOUT, detect_agent_session, require_az, require_gh
38
38
  from .gitrepo import (
39
39
  AdoRemote,
40
40
  GitHubRemote,
@@ -468,18 +468,28 @@ def pr_publish(
468
468
  def pr_status(
469
469
  target_branch: str | None = typer.Option(None, "--target-branch", help=_LOOKUP_TARGET_HELP),
470
470
  wait: bool = typer.Option(False, "--wait", help="Poll until all pipelines/checks are completed; stops early and reports status if one needs manual approval"),
471
+ timeout: float = typer.Option(
472
+ 0,
473
+ "--timeout",
474
+ help="With --wait: stop after this many seconds if the checks are still running, print their current state and exit "
475
+ f"{EXIT_WAIT_TIMEOUT} (0 = wait forever, the default). Use it where the caller's own time limit is shorter than CI, "
476
+ "e.g. an agent's shell tool, then re-run the same command to keep waiting",
477
+ ),
471
478
  pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
472
479
  pat: str | None = typer.Option(None, "--pat", envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"], help="Azure DevOps PAT (else falls back to `az` login)"),
473
480
  ) -> None:
474
481
  """Show build/check status for the PR opened from the current branch, or for --pr-id
475
482
  directly (Azure DevOps or GitHub, auto-detected).
476
483
  """
484
+ if timeout and not wait:
485
+ raise typer.BadParameter("--timeout only works together with --wait", param_hint="--timeout")
486
+ _check_poll_options(None, timeout)
477
487
  remote = current_remote()
478
488
  if isinstance(remote, GitHubRemote):
479
489
  gh = require_gh()
480
- gh_pr.run(gh, wait, pr_id=gh_pr.resolve_pr_id(gh, pr_id, target_branch))
490
+ gh_pr.run(gh, wait, pr_id=gh_pr.resolve_pr_id(gh, pr_id, target_branch), timeout=timeout)
481
491
  return
482
- pr_build.run(remote, pat, target_branch, wait, pr_id=pr_id)
492
+ pr_build.run(remote, pat, target_branch, wait, pr_id=pr_id, timeout=timeout)
483
493
 
484
494
 
485
495
  @pr_app.command("info")
@@ -13,7 +13,9 @@ import os
13
13
  import shutil
14
14
  import sys
15
15
  import time
16
+ from collections.abc import Callable
16
17
  from dataclasses import dataclass
18
+ from typing import NoReturn
17
19
 
18
20
  AZ_INSTALL_HINT = "Install the Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli"
19
21
  GH_INSTALL_HINT = "Install the GitHub CLI: https://cli.github.com"
@@ -26,6 +28,11 @@ PSQL_INSTALL_HINT = "Install the PostgreSQL client tools (psql): https://www.pos
26
28
  # this tool), and callers branching on exit code shouldn't confuse the two.
27
29
  EXIT_NEEDS_APPROVAL = 3
28
30
 
31
+ # `pr status --wait --timeout N` exits with this code when the checks are still running after N seconds
32
+ # (same code as `issue watch` / `issue search --wait` use for "gave up waiting"). The caller learns the
33
+ # current state from the output and can simply run the same command again to keep waiting.
34
+ EXIT_WAIT_TIMEOUT = 4
35
+
29
36
  # How long a `--wait` poll loop may go without printing anything before `PollHeartbeat`
30
37
  # forces a line out anyway. Every `--wait` loop in this tool polls every 30s but used to
31
38
  # print only when the status text changed -- for a check/build that sits in the same
@@ -73,6 +80,42 @@ class PollHeartbeat:
73
80
  self._last_print = now
74
81
 
75
82
 
83
+ class WaitDeadline:
84
+ """Optional overall time limit of a `--wait` poll loop (`timeout_secs` of 0 / None = wait forever).
85
+
86
+ Exists because a caller often can't wait as long as CI takes: an agent's shell tool kills a command
87
+ after a few minutes and throws its output away. Giving up on our own terms lets us report where things
88
+ stand and exit with `EXIT_WAIT_TIMEOUT` so the caller knows to re-run.
89
+ """
90
+
91
+ def __init__(self, timeout_secs: float | None) -> None:
92
+ self.timeout_secs = timeout_secs or 0.0
93
+ self._end = time.monotonic() + self.timeout_secs if self.timeout_secs else None
94
+
95
+ def expired(self) -> bool:
96
+ return self._end is not None and time.monotonic() >= self._end
97
+
98
+ def sleep(self, secs: float) -> None:
99
+ """`time.sleep(secs)`, cut short so the loop wakes up right at the deadline."""
100
+ time.sleep(secs if self._end is None else max(0.0, min(secs, self._end - time.monotonic())))
101
+
102
+
103
+ def status_rerun_cmd(pr_id: int | None, timeout_secs: float) -> str:
104
+ """The command that keeps waiting where a timed-out `pr status --wait` stopped. Pins `--pr-id` to the PR that
105
+ was being watched, so re-running can't resolve to a different PR (e.g. another branch checked out since)."""
106
+ return f"bdt pr status --pr-id {pr_id} --wait --timeout {timeout_secs:g}" if pr_id else f"bdt pr status --wait --timeout {timeout_secs:g}"
107
+
108
+
109
+ def exit_wait_timeout(msg: str, print_details: Callable[[], object], deadline: WaitDeadline, rerun_cmd: str) -> NoReturn:
110
+ """Report where a `--wait` loop stands when its `deadline` ran out (status line, per-item details), then
111
+ exit `EXIT_WAIT_TIMEOUT`. The last line tells the caller what to run to keep waiting."""
112
+ print(msg)
113
+ print("\nDetails:")
114
+ print_details()
115
+ print(f"\nStill running after {deadline.timeout_secs:g} s. Re-run `{rerun_cmd}` to keep waiting.")
116
+ sys.exit(EXIT_WAIT_TIMEOUT)
117
+
118
+
76
119
  def is_claude_code() -> bool:
77
120
  """True if this process is running as a subprocess of Claude Code.
78
121
 
@@ -20,7 +20,17 @@ from pathlib import Path
20
20
  from typing import Any
21
21
 
22
22
  from . import pr_issue_link
23
- from .cli_tools import CLI_TIMEOUT_SECS, EXIT_NEEDS_APPROVAL, PollHeartbeat, detect_agent_session, ensure_agent_session_note, is_claude_code
23
+ from .cli_tools import (
24
+ CLI_TIMEOUT_SECS,
25
+ EXIT_NEEDS_APPROVAL,
26
+ PollHeartbeat,
27
+ WaitDeadline,
28
+ detect_agent_session,
29
+ ensure_agent_session_note,
30
+ exit_wait_timeout,
31
+ is_claude_code,
32
+ status_rerun_cmd,
33
+ )
24
34
  from .gitrepo import current_branch
25
35
  from .pr_choice import PrCandidate, choose_pr
26
36
  from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section
@@ -308,8 +318,11 @@ def exit_needs_approval(msg: str, item_lines: list[str], rerun_cmd: str) -> None
308
318
  sys.exit(EXIT_NEEDS_APPROVAL)
309
319
 
310
320
 
311
- def run(gh: str, wait: bool, pr_id: int | None = None) -> None:
321
+ def run(gh: str, wait: bool, pr_id: int | None = None, timeout: float = 0) -> None:
322
+ """`timeout` (seconds, only with `wait`): stop polling after that long and exit `EXIT_WAIT_TIMEOUT`
323
+ with the current state; 0 = wait until every check is done."""
312
324
  heartbeat = PollHeartbeat()
325
+ deadline = WaitDeadline(timeout)
313
326
  draft_notice_shown = False
314
327
  while True:
315
328
  pr = get_pr(gh, pr_id)
@@ -368,7 +381,9 @@ def run(gh: str, wait: bool, pr_id: int | None = None) -> None:
368
381
  # must not keep polling forever waiting for it to become unstuck.
369
382
  if "pending" in buckets and wait and not waiting_approval:
370
383
  heartbeat.show(msg)
371
- time.sleep(30)
384
+ if deadline.expired():
385
+ exit_wait_timeout(msg, lambda: [print_check(c) for c in checks], deadline, status_rerun_cmd(pr_number, timeout))
386
+ deadline.sleep(30)
372
387
  continue
373
388
 
374
389
  print(msg)
@@ -6,12 +6,13 @@ import re
6
6
  import sys
7
7
  import time
8
8
  from pathlib import Path
9
+ from typing import NoReturn
9
10
  from urllib.parse import quote
10
11
 
11
12
  import requests
12
13
 
13
14
  from .ado_auth import auth_header
14
- from .cli_tools import EXIT_NEEDS_APPROVAL, HTTP_TIMEOUT_SECS, PollHeartbeat, detect_agent_session, ensure_agent_session_note, is_claude_code
15
+ from .cli_tools import EXIT_NEEDS_APPROVAL, HTTP_TIMEOUT_SECS, PollHeartbeat, WaitDeadline, detect_agent_session, ensure_agent_session_note, exit_wait_timeout, is_claude_code, status_rerun_cmd
15
16
  from .gitrepo import AdoRemote, current_branch
16
17
  from .pr_choice import PrCandidate, choose_pr
17
18
  from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section
@@ -766,7 +767,10 @@ def run(
766
767
  wait: bool,
767
768
  source_branch: str | None = None,
768
769
  pr_id: int | None = None,
770
+ timeout: float = 0,
769
771
  ) -> None:
772
+ """`timeout` (seconds, only with `wait`): stop polling after that long and exit `EXIT_WAIT_TIMEOUT`
773
+ with the current state; 0 = wait until every pipeline is done."""
770
774
  session = requests.Session()
771
775
  session.headers.update(auth_header(pat))
772
776
 
@@ -804,8 +808,14 @@ def run(
804
808
  draft_notice_shown = False
805
809
  policy_checked = False
806
810
  heartbeat = PollHeartbeat()
811
+ deadline = WaitDeadline(timeout)
812
+
813
+ def time_out(msg: str, builds: list, pr_number: int) -> NoReturn:
814
+ exit_wait_timeout(msg, lambda: [print_build(session, remote, b) for b in builds], deadline, status_rerun_cmd(pr_number, timeout))
815
+
807
816
  while True:
808
817
  pr = _poll_or_exit(fetch_pr)
818
+ pipeline_builds: list = []
809
819
  if not draft_notice_shown:
810
820
  draft_msg = draft_notice(pr)
811
821
  if draft_msg:
@@ -827,7 +837,9 @@ def run(
827
837
  b.get("definition", {}).get("name", "?") for b in stale
828
838
  )
829
839
  heartbeat.show(msg)
830
- time.sleep(30)
840
+ if deadline.expired():
841
+ time_out(msg, [b for b in pipeline_builds if b not in stale], pr_number)
842
+ deadline.sleep(30)
831
843
  continue
832
844
  msg += " | " + ", ".join(
833
845
  f"{b.get('definition', {}).get('name', '?')} #{b['id']} {b.get('status')} ({b.get('result') or '—'})"
@@ -889,7 +901,9 @@ def run(
889
901
  heartbeat.show(msg)
890
902
 
891
903
  if wait:
892
- time.sleep(30)
904
+ if deadline.expired():
905
+ time_out(msg, pipeline_builds, pr_number)
906
+ deadline.sleep(30)
893
907
 
894
908
 
895
909
  def run_watch_deploy(remote: AdoRemote, pat: str | None, target_branch: str, wait: bool) -> None:
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.41.0"
13
+ version = "0.42.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
@@ -35,7 +35,7 @@ CLI to install rather than trying to work around it.
35
35
  ## Checking PR build/check status
36
36
 
37
37
  ```bash
38
- bdt pr status [--target-branch BRANCH] [--pr-id N] [--wait]
38
+ bdt pr status [--target-branch BRANCH] [--pr-id N] [--wait [--timeout SECONDS]]
39
39
  ```
40
40
 
41
41
  Finds the PR opened from the current branch and prints build/check status,
@@ -48,6 +48,10 @@ error message for `mergeStatus=conflicts` (ADO) or `mergeable=CONFLICTING`
48
48
  (GitHub) and tell the user to resolve conflicts rather than assuming CI is
49
49
  just slow.
50
50
 
51
+ Where your shell tool has a time limit shorter than CI (it kills the command and drops its output), run
52
+ `bdt pr status --wait --timeout 540` (seconds, below the limit): after that long it prints the current state and
53
+ exits 4 — just run the same command again to keep waiting. Exit 3 = a stage needs a human's approval.
54
+
51
55
  `pr status`, `info`, `retry`, `publish`, `update`, `comment` (and `issue take`
52
56
  without a number) find the PR opened from the current branch into **any**
53
57
  target; `--target`/`--target-branch` only narrows it. With several open PRs
@@ -32,7 +32,7 @@ def test_pr_status_with_pr_id_passes_it_to_gh_pr_run(monkeypatch) -> None:
32
32
  monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: GITHUB_REMOTE)
33
33
  monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh")
34
34
  captured = {}
35
- monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.run", lambda gh, wait, pr_id=None: captured.update(gh=gh, wait=wait, pr_id=pr_id))
35
+ monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.run", lambda gh, wait, pr_id=None, timeout=0: captured.update(gh=gh, wait=wait, pr_id=pr_id))
36
36
 
37
37
  result = runner.invoke(app, ["pr", "status", "--pr-id", "99"])
38
38
 
@@ -144,7 +144,7 @@ def test_pr_status_with_pr_id_skips_branch_resolution_for_ado(monkeypatch) -> No
144
144
  captured = {}
145
145
  monkeypatch.setattr(
146
146
  "bmsdna.devtools.cli.pr_build.run",
147
- lambda remote, pat, target_branch, wait, pr_id=None: captured.update(remote=remote, pr_id=pr_id),
147
+ lambda remote, pat, target_branch, wait, pr_id=None, timeout=0: captured.update(remote=remote, pr_id=pr_id),
148
148
  )
149
149
 
150
150
  result = runner.invoke(app, ["pr", "status", "--pr-id", "99"])
@@ -230,7 +230,7 @@ def test_cli_pr_status_interactive_with_several_prs_acts_on_the_chosen_one(monke
230
230
  _github_cli(monkeypatch, [GH_INTO_MAIN, GH_INTO_DEV])
231
231
  monkeypatch.setattr(pr_choice, "_interactive", lambda: True)
232
232
  seen = {}
233
- monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.run", lambda gh, wait, pr_id=None: seen.update(pr_id=pr_id))
233
+ monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.run", lambda gh, wait, pr_id=None, timeout=0: seen.update(pr_id=pr_id))
234
234
 
235
235
  result = runner.invoke(app, ["pr", "status"], input="1\n")
236
236
 
@@ -241,7 +241,7 @@ def test_cli_pr_status_interactive_with_several_prs_acts_on_the_chosen_one(monke
241
241
  def test_cli_pr_status_with_a_single_pr_into_main_does_not_ask(monkeypatch, never_asks) -> None:
242
242
  _github_cli(monkeypatch, [GH_INTO_MAIN])
243
243
  seen = {}
244
- monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.run", lambda gh, wait, pr_id=None: seen.update(pr_id=pr_id))
244
+ monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.run", lambda gh, wait, pr_id=None, timeout=0: seen.update(pr_id=pr_id))
245
245
 
246
246
  result = runner.invoke(app, ["pr", "status"])
247
247
 
@@ -0,0 +1,187 @@
1
+ """`pr status --wait --timeout N`: give up after N seconds with the current state and exit code 4, so a caller
2
+ whose own time limit is shorter than CI (an agent's shell tool, say) gets a result instead of being cut off."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import time
8
+ from unittest.mock import MagicMock
9
+
10
+ import pytest
11
+ from typer.testing import CliRunner
12
+
13
+ from bmsdna.devtools.cli import app
14
+ from bmsdna.devtools.cli_tools import EXIT_WAIT_TIMEOUT, WaitDeadline
15
+ from bmsdna.devtools.gh_pr import run as gh_run
16
+ from bmsdna.devtools.gitrepo import AdoRemote, GitHubRemote
17
+ from bmsdna.devtools.issue_watch import EXIT_TIMEOUT
18
+ from bmsdna.devtools.pr_build import run as ado_run
19
+
20
+ runner = CliRunner()
21
+
22
+
23
+ @pytest.fixture
24
+ def sleeps(monkeypatch) -> list[float]:
25
+ """A fake clock: `time.sleep` only advances `time.monotonic`, and records what it was asked for."""
26
+ now = [1000.0]
27
+ slept: list[float] = []
28
+
29
+ def fake_sleep(secs: float) -> None:
30
+ slept.append(secs)
31
+ now[0] += secs
32
+ if len(slept) > 500:
33
+ pytest.fail("polled forever: the --timeout deadline never fired")
34
+
35
+ monkeypatch.setattr(time, "monotonic", lambda: now[0])
36
+ monkeypatch.setattr(time, "sleep", fake_sleep)
37
+ return slept
38
+
39
+
40
+ def test_exit_code_matches_the_other_wait_commands() -> None:
41
+ assert EXIT_WAIT_TIMEOUT == EXIT_TIMEOUT == 4
42
+
43
+
44
+ def test_deadline_without_timeout_never_expires_and_sleeps_in_full(sleeps) -> None:
45
+ deadline = WaitDeadline(0)
46
+ deadline.sleep(30)
47
+ deadline.sleep(30)
48
+
49
+ assert sleeps == [30, 30]
50
+ assert not deadline.expired()
51
+
52
+
53
+ def test_deadline_cuts_the_last_sleep_short(sleeps) -> None:
54
+ deadline = WaitDeadline(70)
55
+ while not deadline.expired():
56
+ deadline.sleep(30)
57
+
58
+ assert sleeps == [30, 30, 10]
59
+
60
+
61
+ # -- GitHub -------------------------------------------------------------------
62
+
63
+
64
+ def _gh_pr(checks: list[dict]) -> dict:
65
+ return {"number": 42, "title": "feat: widgets", "baseRefName": "main", "mergeable": "MERGEABLE", "isDraft": False, "statusCheckRollup": checks}
66
+
67
+
68
+ PENDING = {"__typename": "CheckRun", "name": "build", "status": "IN_PROGRESS", "workflowName": "CI"}
69
+ PASSED = {"__typename": "CheckRun", "name": "lint", "status": "COMPLETED", "conclusion": "SUCCESS", "workflowName": "CI"}
70
+
71
+
72
+ def test_gh_wait_times_out_with_exit_4_and_reports_every_check(monkeypatch, sleeps, capsys) -> None:
73
+ monkeypatch.setattr("bmsdna.devtools.gh_pr.get_pr", lambda gh, pr_id=None: _gh_pr([PASSED, PENDING]))
74
+
75
+ with pytest.raises(SystemExit) as exc:
76
+ gh_run("gh", wait=True, timeout=70)
77
+
78
+ out = capsys.readouterr().out
79
+ assert exc.value.code == EXIT_WAIT_TIMEOUT
80
+ assert sleeps == [30, 30, 10]
81
+ assert "[✓ PASS] CI / lint" in out and "[… PENDING] CI / build" in out
82
+ assert out.rstrip().endswith("Still running after 70 s. Re-run `bdt pr status --pr-id 42 --wait --timeout 70` to keep waiting.")
83
+
84
+
85
+ def test_gh_wait_without_timeout_keeps_polling(monkeypatch, sleeps) -> None:
86
+ monkeypatch.setattr("bmsdna.devtools.gh_pr.get_pr", lambda gh, pr_id=None: _gh_pr([PENDING]))
87
+
88
+ class Enough(Exception):
89
+ pass
90
+
91
+ def sleep_a_few_times(secs: float) -> None:
92
+ sleeps.append(secs)
93
+ if len(sleeps) > 200:
94
+ raise Enough
95
+
96
+ monkeypatch.setattr(time, "sleep", sleep_a_few_times)
97
+
98
+ with pytest.raises(Enough):
99
+ gh_run("gh", wait=True)
100
+
101
+ assert len(sleeps) > 200
102
+
103
+
104
+ def test_gh_wait_returns_normally_when_checks_finish_before_the_deadline(monkeypatch, sleeps, capsys) -> None:
105
+ polls = iter([_gh_pr([PENDING]), _gh_pr([{**PENDING, "status": "COMPLETED", "conclusion": "SUCCESS"}])])
106
+ monkeypatch.setattr("bmsdna.devtools.gh_pr.get_pr", lambda gh, pr_id=None: next(polls))
107
+ monkeypatch.setattr("bmsdna.devtools.gh_pr.deploy_run_hint", lambda gh, base: None)
108
+
109
+ gh_run("gh", wait=True, timeout=300)
110
+
111
+ assert sleeps == [30]
112
+ assert "Still running" not in capsys.readouterr().out
113
+
114
+
115
+ # -- Azure DevOps ---------------------------------------------------------------
116
+
117
+
118
+ def _ado_env(monkeypatch, builds: list[dict]) -> None:
119
+ monkeypatch.setattr("bmsdna.devtools.pr_build.requests.Session", lambda: MagicMock())
120
+ monkeypatch.setattr("bmsdna.devtools.pr_build.auth_header", lambda pat: {})
121
+ monkeypatch.setattr("bmsdna.devtools.pr_build.get_pr", lambda *a, **k: {"pullRequestId": 42, "title": "feat: widgets", "status": "active", "isDraft": False})
122
+ monkeypatch.setattr("bmsdna.devtools.pr_build.get_builds_for_pr", lambda *a, **k: builds)
123
+ monkeypatch.setattr("bmsdna.devtools.pr_build.find_pending_approvals", lambda *a, **k: [])
124
+ monkeypatch.setattr("bmsdna.devtools.pr_build.print_build", lambda session, remote, b: print(f" build #{b['id']} {b['status']}"))
125
+
126
+
127
+ def test_ado_wait_times_out_with_exit_4_and_reports_the_running_builds(monkeypatch, sleeps, capsys) -> None:
128
+ _ado_env(monkeypatch, [{"id": 200, "status": "inProgress", "result": None, "definition": {"id": 2, "name": "ci"}}])
129
+
130
+ with pytest.raises(SystemExit) as exc:
131
+ ado_run(AdoRemote("myorg", "MyProj", "myrepo"), None, "main", wait=True, source_branch="feature-x", timeout=45)
132
+
133
+ out = capsys.readouterr().out
134
+ assert exc.value.code == EXIT_WAIT_TIMEOUT
135
+ assert sleeps == [30, 15]
136
+ assert "ci #200 inProgress" in out and "build #200 inProgress" in out
137
+ assert out.rstrip().endswith("Still running after 45 s. Re-run `bdt pr status --pr-id 42 --wait --timeout 45` to keep waiting.")
138
+
139
+
140
+ def test_ado_wait_times_out_while_no_build_has_been_queued_yet(monkeypatch, sleeps, capsys) -> None:
141
+ _ado_env(monkeypatch, [])
142
+ monkeypatch.setattr("bmsdna.devtools.pr_build.lacks_build_policy", lambda *a: False)
143
+
144
+ with pytest.raises(SystemExit) as exc:
145
+ ado_run(AdoRemote("myorg", "MyProj", "myrepo"), None, "main", wait=True, source_branch="feature-x", timeout=60)
146
+
147
+ assert exc.value.code == EXIT_WAIT_TIMEOUT
148
+ assert "No builds found." in capsys.readouterr().out
149
+
150
+
151
+ # -- CLI ----------------------------------------------------------------------
152
+
153
+
154
+ def test_cli_passes_timeout_on(monkeypatch) -> None:
155
+ monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: GitHubRemote("owner", "repo"))
156
+ monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh")
157
+ monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.resolve_pr_id", lambda gh, pr_id, target: pr_id)
158
+ seen: dict = {}
159
+ monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.run", lambda gh, wait, pr_id=None, timeout=0: seen.update(wait=wait, timeout=timeout))
160
+
161
+ result = runner.invoke(app, ["pr", "status", "--wait", "--timeout", "540"])
162
+
163
+ assert result.exit_code == 0, result.output
164
+ assert seen == {"wait": True, "timeout": 540}
165
+
166
+
167
+ def _plain(output: str) -> str:
168
+ """Rich colors and boxes the error panel on CI terminals; reduce it to the bare words."""
169
+ return " ".join(re.sub(r"[│╭╮╰╯─]", " ", re.sub(r"\x1b\[[0-9;]*m", "", output)).split())
170
+
171
+
172
+ def test_cli_timeout_needs_wait(monkeypatch) -> None:
173
+ monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: pytest.fail("must be rejected before anything runs"))
174
+
175
+ result = runner.invoke(app, ["pr", "status", "--timeout", "540"])
176
+
177
+ assert result.exit_code == 2
178
+ assert "--timeout only works together with --wait" in _plain(result.output)
179
+
180
+
181
+ @pytest.mark.parametrize("bad", ["-1", "nan", "inf"])
182
+ def test_cli_rejects_a_timeout_that_is_not_a_finite_number(monkeypatch, bad) -> None:
183
+ monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: pytest.fail("must be rejected before anything runs"))
184
+
185
+ result = runner.invoke(app, ["pr", "status", "--wait", "--timeout", bad])
186
+
187
+ assert result.exit_code == 2
@@ -22,7 +22,7 @@ wheels = [
22
22
 
23
23
  [[package]]
24
24
  name = "bmsdna-devtools"
25
- version = "0.41.0"
25
+ version = "0.42.0"
26
26
  source = { editable = "." }
27
27
  dependencies = [
28
28
  { name = "pgdevkit", extra = ["db"] },