bmsdna-devtools 0.44.0__tar.gz → 0.45.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 (124) hide show
  1. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/PKG-INFO +14 -2
  2. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/README.md +13 -1
  3. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/bdt_config.py +5 -1
  4. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/cli.py +12 -2
  5. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/pr_build.py +32 -5
  6. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/worktree.py +40 -1
  7. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/pyproject.toml +1 -1
  8. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_bdt_config.py +21 -0
  9. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_cli_pr_id.py +49 -0
  10. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_pr_build.py +4 -13
  11. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_pr_build_flow.py +46 -0
  12. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_worktree_clean.py +66 -0
  13. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/uv.lock +1 -1
  14. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/.github/workflows/auto-release.yml +0 -0
  15. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/.github/workflows/python-publish.yml +0 -0
  16. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/.github/workflows/python-test.yml +0 -0
  17. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/.gitignore +0 -0
  18. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/.python-version +0 -0
  19. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/__init__.py +0 -0
  20. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/_api_dump.py +0 -0
  21. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/ado_auth.py +0 -0
  22. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/ado_issue.py +0 -0
  23. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/agent_mentions.py +0 -0
  24. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/api_usage.py +0 -0
  25. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/app_service_logs.py +0 -0
  26. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/azure_arm.py +0 -0
  27. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/azure_auth.py +0 -0
  28. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/cli_tools.py +0 -0
  29. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/commit.py +0 -0
  30. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/dead_code.py +0 -0
  31. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/env_config.py +0 -0
  32. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/env_files.py +0 -0
  33. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/find_injection.py +0 -0
  34. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/find_repo.py +0 -0
  35. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/gh_args.py +0 -0
  36. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/gh_issue.py +0 -0
  37. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/gh_pr.py +0 -0
  38. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/github_api.py +0 -0
  39. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/gitrepo.py +0 -0
  40. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/injection_frontend.py +0 -0
  41. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/injection_python.py +0 -0
  42. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/issue_do.py +0 -0
  43. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/issue_show.py +0 -0
  44. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/issue_watch.py +0 -0
  45. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/lint.py +0 -0
  46. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/lint_findings.py +0 -0
  47. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/lint_models.py +0 -0
  48. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/lint_sql.py +0 -0
  49. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  50. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/lint_tooling.py +0 -0
  51. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/lint_typescript.py +0 -0
  52. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/logs.py +0 -0
  53. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/pr_choice.py +0 -0
  54. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/pr_create_args.py +0 -0
  55. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/pr_info.py +0 -0
  56. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  57. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/pr_labels.py +0 -0
  58. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/pr_markdown.py +0 -0
  59. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/pull.py +0 -0
  60. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/testdb.py +0 -0
  61. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/translate.py +0 -0
  62. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/usage/__init__.py +0 -0
  63. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/usage/cli.py +0 -0
  64. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/usage/pipeline.py +0 -0
  65. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/usage/pricing.py +0 -0
  66. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/usage/report.py +0 -0
  67. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/usage/sql/00_schema.sql +0 -0
  68. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/usage/sql/01_ingest_claude.sql +0 -0
  69. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/usage/sql/01_ingest_codex.sql +0 -0
  70. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/usage/sql/02_messages.sql +0 -0
  71. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/usage/sql/03_markers.sql +0 -0
  72. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/usage/sql/04_facts.sql +0 -0
  73. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/bmsdna/devtools/usage/sql/05_views.sql +0 -0
  74. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/justfile +0 -0
  75. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  76. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/conftest.py +0 -0
  77. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/fake_github.py +0 -0
  78. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_ado_auth.py +0 -0
  79. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_ado_issue.py +0 -0
  80. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_ado_issue_flow.py +0 -0
  81. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_agent_mentions.py +0 -0
  82. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_api_usage.py +0 -0
  83. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_app_service_logs.py +0 -0
  84. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_azure_arm.py +0 -0
  85. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_cli_cleanup.py +0 -0
  86. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_cli_commit.py +0 -0
  87. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_cli_issue.py +0 -0
  88. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_cli_pr_create.py +0 -0
  89. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_cli_pull.py +0 -0
  90. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_cli_tools.py +0 -0
  91. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_cli_version.py +0 -0
  92. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_commit.py +0 -0
  93. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_env_config.py +0 -0
  94. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_env_files.py +0 -0
  95. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_find_injection.py +0 -0
  96. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_find_repo.py +0 -0
  97. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_gh_issue.py +0 -0
  98. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_gh_pr.py +0 -0
  99. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_github_api.py +0 -0
  100. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_gitrepo.py +0 -0
  101. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_issue_do.py +0 -0
  102. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_issue_for_me.py +0 -0
  103. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_issue_show.py +0 -0
  104. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_issue_watch.py +0 -0
  105. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_lint.py +0 -0
  106. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_lint_models.py +0 -0
  107. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_lint_sql.py +0 -0
  108. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_lint_sql_files.py +0 -0
  109. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_lint_tooling.py +0 -0
  110. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_lint_typescript.py +0 -0
  111. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_logs.py +0 -0
  112. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_pr_build_create_options.py +0 -0
  113. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_pr_choice.py +0 -0
  114. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_pr_create_args.py +0 -0
  115. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_pr_description_not_truncated.py +0 -0
  116. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_pr_info.py +0 -0
  117. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_pr_issue_link.py +0 -0
  118. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_pr_labels.py +0 -0
  119. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_pr_markdown.py +0 -0
  120. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_pull.py +0 -0
  121. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_testdb.py +0 -0
  122. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_translate.py +0 -0
  123. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_usage.py +0 -0
  124. {bmsdna_devtools-0.44.0 → bmsdna_devtools-0.45.0}/tests/test_wait_timeout.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bmsdna-devtools
3
- Version: 0.44.0
3
+ Version: 0.45.0
4
4
  Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: azure-identity>=1.19.0
@@ -283,6 +283,16 @@ create prints a reminder to run `bdt pr status` afterward to check whether
283
283
  the CI build passes. This is a best-effort check — failures reading policy
284
284
  config (auth, permissions) fail open and simply skip the reminder.
285
285
 
286
+ ## `bdt pr comment`
287
+
288
+ ```bash
289
+ bdt pr comment --message "Looks good" [--screenshot shot.png] [--file report.pdf] [--pr-id 123]
290
+ bdt pr comment --message-file plan.md # post a file's text as the comment (`-` = stdin)
291
+ ```
292
+
293
+ `--file` links the file as an *attachment* (a download link in the comment); it does not post the file's
294
+ content. To post a markdown document (an implementation plan, review findings) use `--message-file`.
295
+
286
296
  ## `bdt issue create` / `show` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
287
297
 
288
298
  ```bash
@@ -559,7 +569,9 @@ scan_jinja = ["backend/print"] # "KEY" | tr in .jinja2/.j2/.html
559
569
  bdt worktree my-feature [--base dev] [--env-file .local_env] [--no-submodules] [--install "just install"]
560
570
  ```
561
571
 
562
- Creates `.worktrees/<name>` branched from `--base`, initializes submodules
572
+ Creates `.worktrees/<name>` branched from `--base`. It first fetches `origin/<base>` and, if the local
573
+ `<base>` is strictly behind it, branches from `origin/<base>` instead (so the PR starts on current code);
574
+ a local base that is ahead of or has diverged from the remote is kept as is. Then it initializes submodules
563
575
  (unless `--no-submodules`), and copies an env file into the new worktree as
564
576
  `.env` (auto-detects `.local_env` then `.env` if `--env-file` isn't given).
565
577
 
@@ -265,6 +265,16 @@ create prints a reminder to run `bdt pr status` afterward to check whether
265
265
  the CI build passes. This is a best-effort check — failures reading policy
266
266
  config (auth, permissions) fail open and simply skip the reminder.
267
267
 
268
+ ## `bdt pr comment`
269
+
270
+ ```bash
271
+ bdt pr comment --message "Looks good" [--screenshot shot.png] [--file report.pdf] [--pr-id 123]
272
+ bdt pr comment --message-file plan.md # post a file's text as the comment (`-` = stdin)
273
+ ```
274
+
275
+ `--file` links the file as an *attachment* (a download link in the comment); it does not post the file's
276
+ content. To post a markdown document (an implementation plan, review findings) use `--message-file`.
277
+
268
278
  ## `bdt issue create` / `show` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
269
279
 
270
280
  ```bash
@@ -541,7 +551,9 @@ scan_jinja = ["backend/print"] # "KEY" | tr in .jinja2/.j2/.html
541
551
  bdt worktree my-feature [--base dev] [--env-file .local_env] [--no-submodules] [--install "just install"]
542
552
  ```
543
553
 
544
- Creates `.worktrees/<name>` branched from `--base`, initializes submodules
554
+ Creates `.worktrees/<name>` branched from `--base`. It first fetches `origin/<base>` and, if the local
555
+ `<base>` is strictly behind it, branches from `origin/<base>` instead (so the PR starts on current code);
556
+ a local base that is ahead of or has diverged from the remote is kept as is. Then it initializes submodules
545
557
  (unless `--no-submodules`), and copies an env file into the new worktree as
546
558
  `.env` (auto-detects `.local_env` then `.env` if `--env-file` isn't given).
547
559
 
@@ -1,6 +1,8 @@
1
1
  """Shared lookup for `[tool.bdt.*]` configuration tables in the consuming
2
2
  repo's pyproject.toml (walks up from a start directory the same way `git`
3
- looks for `.git`, so it works from any subdirectory of the repo).
3
+ looks for `.git`, so it works from any subdirectory of the repo). The walk stops at the
4
+ repository root (the first directory holding a `.git` dir or file), so a submodule or nested
5
+ repo never picks up the config of the repo that contains it.
4
6
  """
5
7
 
6
8
  from __future__ import annotations
@@ -15,6 +17,8 @@ def find_pyproject(start: Path | None = None) -> Path | None:
15
17
  candidate = directory / "pyproject.toml"
16
18
  if candidate.is_file():
17
19
  return candidate
20
+ if (directory / ".git").exists():
21
+ return None
18
22
  return None
19
23
 
20
24
 
@@ -629,11 +629,14 @@ def pr_update(
629
629
  @pr_app.command("comment")
630
630
  def pr_comment(
631
631
  message: str | None = typer.Option(None, "--message", help="Comment text"),
632
+ message_file: str | None = typer.Option(
633
+ None, "--message-file", help="Read the comment text from this file (`-` = stdin); unlike --file, the content is posted as the comment"
634
+ ),
632
635
  screenshot: list[str] = typer.Option(
633
636
  [], "--screenshot", help="Path to an image to embed in the comment (repeatable)"
634
637
  ),
635
638
  file: list[str] = typer.Option(
636
- [], "--file", help="Path to an arbitrary file to link in the comment as an attachment (repeatable)"
639
+ [], "--file", help="Path to a file to link in the comment as an attachment (repeatable); to post a file's text as the comment use --message-file"
637
640
  ),
638
641
  target: str | None = typer.Option(None, "--target", help=_LOOKUP_TARGET_HELP),
639
642
  pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
@@ -647,6 +650,13 @@ def pr_comment(
647
650
  """Post a comment on the PR opened from the current branch, or --pr-id directly
648
651
  (Azure DevOps or GitHub, auto-detected).
649
652
  """
653
+ if message_file is not None:
654
+ if message is not None:
655
+ raise typer.BadParameter("pass either --message or --message-file, not both", param_hint="--message-file")
656
+ try:
657
+ message = sys.stdin.read() if message_file == "-" else Path(message_file).read_text(encoding="utf-8")
658
+ except OSError as e:
659
+ raise typer.BadParameter(f"cannot read {message_file!r}: {e}", param_hint="--message-file") from e
650
660
  for path in screenshot:
651
661
  if not Path(path).is_file():
652
662
  raise typer.BadParameter(f"Screenshot not found: {path}", param_hint="--screenshot")
@@ -654,7 +664,7 @@ def pr_comment(
654
664
  if not Path(path).is_file():
655
665
  raise typer.BadParameter(f"File not found: {path}", param_hint="--file")
656
666
  if not message and not screenshot and not file:
657
- raise typer.BadParameter("Provide at least one of --message, --screenshot, --file")
667
+ raise typer.BadParameter("Provide at least one of --message, --message-file, --screenshot, --file")
658
668
 
659
669
  remote = current_remote()
660
670
  if isinstance(remote, GitHubRemote):
@@ -561,13 +561,40 @@ def update(
561
561
  def link_work_item(session: requests.Session, remote: AdoRemote, pr_id: int, work_item_id: int) -> None:
562
562
  """Link work item `work_item_id` to PR `pr_id`, so it shows up in the PR's "Work Items" tab
563
563
  (and the work item's own "Development" links) -- the Azure DevOps equivalent of GitHub's
564
- closing-keyword PR<->issue link, via the dedicated Pull Request Work Items REST resource
565
- (POST .../pullRequests/{pr_id}/workitems/{work_item_id}, the same one `az repos pr work-item
566
- add` itself calls). Idempotent -- linking an already-linked work item just re-confirms it.
564
+ closing-keyword PR<->issue link.
565
+
566
+ There is no REST resource that links "PR -> work item" directly; what `az repos pr work-item add`
567
+ does (and so do we) is add an `ArtifactLink` relation to the *work item*, pointing at the PR's
568
+ `vstfs:///Git/PullRequestId/{projectId}%2F{repoId}%2F{prId}` artifact URI. Idempotent -- an
569
+ already-linked work item is left alone (adding the relation twice would be rejected).
567
570
  """
568
- r = session.post(
569
- f"{_base_url(remote)}/_apis/git/repositories/{quote(remote.repo, safe='')}/pullRequests/{pr_id}/workitems/{work_item_id}",
571
+ r = session.get(
572
+ f"{_base_url(remote)}/_apis/git/repositories/{quote(remote.repo, safe='')}/pullrequests/{pr_id}",
573
+ params={"api-version": "7.1"},
574
+ timeout=HTTP_TIMEOUT_SECS,
575
+ )
576
+ r.raise_for_status()
577
+ repository = r.json()["repository"]
578
+ artifact_url = f"vstfs:///Git/PullRequestId/{repository['project']['id']}%2F{repository['id']}%2F{pr_id}"
579
+
580
+ work_item_url = f"{_base_url(remote)}/_apis/wit/workitems/{work_item_id}"
581
+ r = session.get(work_item_url, params={"$expand": "relations", "api-version": "7.1"}, timeout=HTTP_TIMEOUT_SECS)
582
+ r.raise_for_status()
583
+ existing = r.json().get("relations") or []
584
+ if any(rel.get("rel") == "ArtifactLink" and str(rel.get("url", "")).lower() == artifact_url.lower() for rel in existing):
585
+ return
586
+
587
+ r = session.patch(
588
+ work_item_url,
570
589
  params={"api-version": "7.1"},
590
+ json=[
591
+ {
592
+ "op": "add",
593
+ "path": "/relations/-",
594
+ "value": {"rel": "ArtifactLink", "url": artifact_url, "attributes": {"name": "Pull Request"}},
595
+ }
596
+ ],
597
+ headers={"Content-Type": "application/json-patch+json"},
571
598
  timeout=HTTP_TIMEOUT_SECS,
572
599
  )
573
600
  r.raise_for_status()
@@ -59,6 +59,43 @@ def _run_capture(cmd: list[str], cwd: Path | None = None) -> subprocess.Complete
59
59
  return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=False)
60
60
 
61
61
 
62
+ def _git_ok(args: list[str], cwd: Path, timeout: float = 5.0) -> bool:
63
+ try:
64
+ return subprocess.run(["git", *args], cwd=cwd, capture_output=True, check=False, timeout=timeout).returncode == 0
65
+ except (FileNotFoundError, subprocess.TimeoutExpired):
66
+ return False
67
+
68
+
69
+ def _start_point(root: Path, base: str) -> tuple[str, bool]:
70
+ """Where a new worktree branch should start, and whether that is a remote-tracking ref.
71
+
72
+ `base` is usually a long-lived local branch (`dev`) that nobody keeps up to date, so a worktree
73
+ branched from it starts behind -- the PR then shows merge conflicts and the plan gets written
74
+ against stale code. Fetch `origin/<base>` first and start from it when the local branch is
75
+ strictly behind. A local branch that is ahead of / diverged from the remote is left alone (its
76
+ unpushed work is on purpose), as is everything when there is no `origin` or it can't be reached.
77
+ """
78
+ local = f"refs/heads/{base}"
79
+ remote = f"refs/remotes/origin/{base}"
80
+ has_local = _git_ok(["rev-parse", "--verify", "--quiet", local], root)
81
+ if not _git_ok(["remote", "get-url", "origin"], root):
82
+ return base, False
83
+ if not _git_ok(["fetch", "--quiet", "origin", f"+refs/heads/{base}:{remote}"], root, timeout=pull_mod.CLI_TIMEOUT_SECS):
84
+ if has_local:
85
+ print(f"warning: could not fetch origin/{base}; branching from the local {base}, which may be behind", file=sys.stderr)
86
+ return base, False
87
+ if not has_local:
88
+ return f"origin/{base}", True
89
+ if _git_ok(["merge-base", "--is-ancestor", remote, local], root):
90
+ return base, False # up to date or ahead of the remote
91
+ if _git_ok(["merge-base", "--is-ancestor", local, remote], root):
92
+ behind = _run_capture(["git", "rev-list", "--count", f"{local}..{remote}"], cwd=root).stdout.strip()
93
+ print(f"{base} is {behind} commit(s) behind origin/{base}; branching from origin/{base}")
94
+ return f"origin/{base}", True
95
+ print(f"warning: local {base} and origin/{base} have diverged; branching from the local {base}", file=sys.stderr)
96
+ return base, False
97
+
98
+
62
99
  def create(
63
100
  name: str,
64
101
  base: str = "dev",
@@ -72,7 +109,9 @@ def create(
72
109
  if path.exists():
73
110
  sys.exit(f"error: {path} already exists")
74
111
 
75
- _run(["git", "worktree", "add", str(path), "-b", name, base], cwd=root)
112
+ start, remote_start = _start_point(root, base)
113
+ # --no-track: the new branch must not push to / compare against origin/<base>
114
+ _run(["git", "worktree", "add", *(["--no-track"] if remote_start else []), str(path), "-b", name, start], cwd=root)
76
115
 
77
116
  if submodules and (root / ".gitmodules").exists():
78
117
  _run(["git", "submodule", "update", "--init"], cwd=path)
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.44.0"
13
+ version = "0.45.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
@@ -29,3 +29,24 @@ def test_load_bdt_table_returns_empty_when_key_absent(tmp_path) -> None:
29
29
  def test_load_bdt_table_reads_configured_table(tmp_path) -> None:
30
30
  write_pyproject(tmp_path, "[tool.bdt.ado]\nboard = 'My Team'\n")
31
31
  assert load_bdt_table("ado", tmp_path) == {"board": "My Team"}
32
+
33
+
34
+ def test_find_pyproject_stops_at_the_repo_root(tmp_path) -> None:
35
+ """A submodule (its `.git` is a file) is another repository: the parent repo's config must not apply."""
36
+ write_pyproject(tmp_path, "[tool.bdt.pr.required_labels]\ne2e_area = ['e2e-full']\n")
37
+ (tmp_path / ".git").mkdir()
38
+ sub = tmp_path / "database"
39
+ (sub / "src").mkdir(parents=True)
40
+ (sub / ".git").write_text("gitdir: ../.git/modules/database\n")
41
+
42
+ assert find_pyproject(sub / "src") is None
43
+ assert load_bdt_table("pr", sub / "src") == {}
44
+
45
+
46
+ def test_find_pyproject_prefers_the_repo_roots_own_pyproject(tmp_path) -> None:
47
+ write_pyproject(tmp_path, "[project]\nname = 'x'\n")
48
+ (tmp_path / ".git").mkdir()
49
+ nested = tmp_path / "a" / "b"
50
+ nested.mkdir(parents=True)
51
+
52
+ assert find_pyproject(nested) == tmp_path / "pyproject.toml"
@@ -232,3 +232,52 @@ def test_pr_comment_with_pr_id_resolves_by_id_and_skips_branch_for_ado(monkeypat
232
232
 
233
233
  assert result.exit_code == 0, result.output
234
234
  assert captured == {"pr_id": 99, "message": "hi"}
235
+
236
+
237
+ def test_pr_comment_message_file_posts_the_file_content(monkeypatch, tmp_path) -> None:
238
+ monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: GITHUB_REMOTE)
239
+ monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh")
240
+ captured = {}
241
+ monkeypatch.setattr(
242
+ "bmsdna.devtools.cli.gh_pr.comment_with_screenshots",
243
+ lambda gh, owner, repo, branch, message, screenshot, file, pr_id=None: captured.update(message=message, file=file),
244
+ )
245
+ plan = tmp_path / "plan.md"
246
+ plan.write_text("# Plan\n\n- step 1\n", encoding="utf-8")
247
+
248
+ result = runner.invoke(app, ["pr", "comment", "--pr-id", "99", "--message-file", str(plan)])
249
+
250
+ assert result.exit_code == 0, result.output
251
+ assert captured == {"message": "# Plan\n\n- step 1\n", "file": []}
252
+
253
+
254
+ def test_pr_comment_message_file_reads_stdin_for_dash(monkeypatch) -> None:
255
+ monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: GITHUB_REMOTE)
256
+ monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh")
257
+ captured = {}
258
+ monkeypatch.setattr(
259
+ "bmsdna.devtools.cli.gh_pr.comment_with_screenshots",
260
+ lambda gh, owner, repo, branch, message, screenshot, file, pr_id=None: captured.update(message=message),
261
+ )
262
+
263
+ result = runner.invoke(app, ["pr", "comment", "--pr-id", "99", "--message-file", "-"], input="from stdin")
264
+
265
+ assert result.exit_code == 0, result.output
266
+ assert captured == {"message": "from stdin"}
267
+
268
+
269
+ def test_pr_comment_message_and_message_file_are_exclusive(tmp_path) -> None:
270
+ plan = tmp_path / "plan.md"
271
+ plan.write_text("x", encoding="utf-8")
272
+
273
+ result = runner.invoke(app, ["pr", "comment", "--message", "hi", "--message-file", str(plan)])
274
+
275
+ assert result.exit_code != 0
276
+ assert "not both" in result.output
277
+
278
+
279
+ def test_pr_comment_message_file_missing_is_a_usage_error(tmp_path) -> None:
280
+ result = runner.invoke(app, ["pr", "comment", "--message-file", str(tmp_path / "nope.md")])
281
+
282
+ assert result.exit_code == 2
283
+ assert "cannot read" in result.output
@@ -418,24 +418,15 @@ def test_get_pr_by_id_exits_on_merge_conflict() -> None:
418
418
  # -- link_work_item -----------------------------------------------------------
419
419
 
420
420
 
421
- def test_link_work_item_posts_to_the_pr_work_items_endpoint() -> None:
422
- remote = AdoRemote("myorg", "MyProj", "myrepo")
423
- session = MagicMock()
424
- session.post.return_value = MagicMock(status_code=200)
425
-
426
- link_work_item(session, remote, pr_id=456, work_item_id=99)
427
-
428
- url = session.post.call_args.args[0]
429
- assert url == "https://dev.azure.com/myorg/MyProj/_apis/git/repositories/myrepo/pullRequests/456/workitems/99"
430
- session.post.return_value.raise_for_status.assert_called_once()
431
-
432
-
433
421
  def test_link_work_item_raises_on_http_error() -> None:
434
422
  remote = AdoRemote("myorg", "MyProj", "myrepo")
435
423
  session = MagicMock()
424
+ session.get.return_value = MagicMock(
425
+ status_code=200, json=lambda: {"repository": {"id": "r", "project": {"id": "p"}}, "relations": []}
426
+ )
436
427
  response = MagicMock(status_code=404)
437
428
  response.raise_for_status.side_effect = requests.HTTPError("not found")
438
- session.post.return_value = response
429
+ session.patch.return_value = response
439
430
 
440
431
  with pytest.raises(requests.HTTPError):
441
432
  link_work_item(session, remote, pr_id=456, work_item_id=99)
@@ -651,3 +651,49 @@ def test_run_wait_checks_build_policy_only_once(monkeypatch) -> None:
651
651
  run(REMOTE, "fake-pat", "main", wait=True, source_branch="feature-x")
652
652
 
653
653
  assert calls == ["main"]
654
+
655
+
656
+ # --- link_work_item -------------------------------------------------------
657
+
658
+ _PR_BODY = {"pullRequestId": 42, "repository": {"id": "repo-guid", "project": {"id": "proj-guid"}}}
659
+ _ARTIFACT = "vstfs:///Git/PullRequestId/proj-guid%2Frepo-guid%2F42"
660
+
661
+
662
+ def _link_session(relations: list[dict]) -> MagicMock:
663
+ session = MagicMock()
664
+
665
+ def fake_get(url, params=None, **kwargs):
666
+ if "/pullrequests/42" in url:
667
+ return FakeResponse(_PR_BODY)
668
+ if "/_apis/wit/workitems/7" in url:
669
+ return FakeResponse({"id": 7, "relations": relations})
670
+ raise AssertionError(f"unexpected GET {url}")
671
+
672
+ session.get.side_effect = fake_get
673
+ session.patch.return_value = FakeResponse({})
674
+ return session
675
+
676
+
677
+ def test_link_work_item_adds_an_artifact_link_relation_on_the_work_item() -> None:
678
+ from bmsdna.devtools.pr_build import link_work_item
679
+
680
+ session = _link_session([{"rel": "AttachedFile", "url": "https://x/attachment"}])
681
+
682
+ link_work_item(session, AdoRemote(org="myorg", project="BMS - CCMT2", repo="BMS - CCMT2"), 42, 7)
683
+
684
+ (url,), kwargs = session.patch.call_args
685
+ assert url == "https://dev.azure.com/myorg/BMS%20-%20CCMT2/_apis/wit/workitems/7"
686
+ assert kwargs["headers"] == {"Content-Type": "application/json-patch+json"}
687
+ assert kwargs["json"] == [
688
+ {"op": "add", "path": "/relations/-", "value": {"rel": "ArtifactLink", "url": _ARTIFACT, "attributes": {"name": "Pull Request"}}}
689
+ ]
690
+
691
+
692
+ def test_link_work_item_is_idempotent() -> None:
693
+ from bmsdna.devtools.pr_build import link_work_item
694
+
695
+ session = _link_session([{"rel": "ArtifactLink", "url": _ARTIFACT.upper().replace("VSTFS", "vstfs")}])
696
+
697
+ link_work_item(session, REMOTE, 42, 7)
698
+
699
+ session.patch.assert_not_called()
@@ -108,6 +108,72 @@ def test_create_does_not_hint_no_default_when_base_is_main_but_remotes_real_defa
108
108
  assert "--no-default" not in out
109
109
 
110
110
 
111
+ # --- create: starts from a fetched origin/<base> -------------------------
112
+
113
+
114
+ def _clone_with_dev(tmp_path):
115
+ """A clone whose `origin` has a `dev` branch; returns (origin, clone)."""
116
+ origin = init_repo(tmp_path / "origin")
117
+ _git(["branch", "dev"], cwd=origin)
118
+ clone = tmp_path / "clone"
119
+ _git(["clone", "-q", str(origin), str(clone)], cwd=tmp_path)
120
+ _git(["config", "user.email", "test@example.com"], cwd=clone)
121
+ _git(["config", "user.name", "Test"], cwd=clone)
122
+ _git(["branch", "dev", "origin/dev"], cwd=clone)
123
+ return origin, clone
124
+
125
+
126
+ def _commit(repo, branch, filename):
127
+ _git(["checkout", "-q", branch], cwd=repo)
128
+ (repo / filename).write_text(filename)
129
+ _git(["add", "."], cwd=repo)
130
+ _git(["commit", "-q", "-m", filename], cwd=repo)
131
+ _git(["checkout", "-q", "main"], cwd=repo)
132
+
133
+
134
+ def test_create_branches_from_fetched_origin_when_local_base_is_behind(tmp_path, capsys) -> None:
135
+ origin, clone = _clone_with_dev(tmp_path)
136
+ _commit(origin, "dev", "new-on-origin.txt")
137
+
138
+ path = create("my-feature", base="dev", submodules=False, root=clone)
139
+
140
+ assert (path / "new-on-origin.txt").exists()
141
+ assert "1 commit(s) behind origin/dev" in capsys.readouterr().out
142
+ # not tracking origin/dev: a plain `git push` must not target the base branch
143
+ assert _git(["config", "--get", "branch.my-feature.remote"], cwd=clone, check=False).stdout.strip() == ""
144
+
145
+
146
+ def test_create_keeps_local_base_when_it_is_ahead_of_origin(tmp_path) -> None:
147
+ _, clone = _clone_with_dev(tmp_path)
148
+ _commit(clone, "dev", "local-only.txt")
149
+
150
+ path = create("my-feature", base="dev", submodules=False, root=clone)
151
+
152
+ assert (path / "local-only.txt").exists()
153
+
154
+
155
+ def test_create_warns_and_keeps_local_base_when_diverged(tmp_path, capsys) -> None:
156
+ origin, clone = _clone_with_dev(tmp_path)
157
+ _commit(origin, "dev", "on-origin.txt")
158
+ _commit(clone, "dev", "local-only.txt")
159
+
160
+ path = create("my-feature", base="dev", submodules=False, root=clone)
161
+
162
+ assert (path / "local-only.txt").exists()
163
+ assert not (path / "on-origin.txt").exists()
164
+ assert "diverged" in capsys.readouterr().err
165
+
166
+
167
+ def test_create_uses_local_base_without_an_origin_remote(tmp_path, capsys) -> None:
168
+ repo = init_repo(tmp_path / "repo")
169
+ _git(["branch", "dev"], cwd=repo)
170
+
171
+ path = create("my-feature", base="dev", submodules=False, root=repo)
172
+
173
+ assert path.exists()
174
+ assert "warning" not in capsys.readouterr().err
175
+
176
+
111
177
  # --- find_repos -----------------------------------------------------------
112
178
 
113
179
 
@@ -51,7 +51,7 @@ wheels = [
51
51
 
52
52
  [[package]]
53
53
  name = "bmsdna-devtools"
54
- version = "0.44.0"
54
+ version = "0.45.0"
55
55
  source = { editable = "." }
56
56
  dependencies = [
57
57
  { name = "azure-identity" },