bmsdna-devtools 0.20.0__tar.gz → 0.22.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 (52) hide show
  1. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/PKG-INFO +42 -9
  2. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/README.md +41 -8
  3. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/cli.py +44 -3
  4. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/commit.py +87 -6
  5. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/find_repo.py +96 -29
  6. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/gh_pr.py +16 -0
  7. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/pr_build.py +13 -0
  8. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/pyproject.toml +1 -1
  9. bmsdna_devtools-0.22.0/tests/test_cli_commit.py +92 -0
  10. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_commit.py +183 -1
  11. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_find_repo.py +157 -2
  12. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_gh_pr.py +29 -0
  13. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_pr_build_flow.py +18 -0
  14. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/uv.lock +1 -1
  15. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/.github/workflows/auto-release.yml +0 -0
  16. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/.github/workflows/python-publish.yml +0 -0
  17. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/.github/workflows/python-test.yml +0 -0
  18. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/.gitignore +0 -0
  19. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/.python-version +0 -0
  20. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/__init__.py +0 -0
  21. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/ado_auth.py +0 -0
  22. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/ado_issue.py +0 -0
  23. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/app_service_logs.py +0 -0
  24. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/bdt_config.py +0 -0
  25. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/cli_tools.py +0 -0
  26. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/env_config.py +0 -0
  27. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/gh_issue.py +0 -0
  28. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/gitrepo.py +0 -0
  29. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/logs.py +0 -0
  30. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/pr_labels.py +0 -0
  31. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/pr_markdown.py +0 -0
  32. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/testdb.py +0 -0
  33. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/worktree.py +0 -0
  34. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/justfile +0 -0
  35. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  36. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/conftest.py +0 -0
  37. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_ado_auth.py +0 -0
  38. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_ado_issue.py +0 -0
  39. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_ado_issue_flow.py +0 -0
  40. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_app_service_logs.py +0 -0
  41. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_bdt_config.py +0 -0
  42. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_cli_cleanup.py +0 -0
  43. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_cli_pr_create.py +0 -0
  44. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_cli_tools.py +0 -0
  45. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_env_config.py +0 -0
  46. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_gh_issue.py +0 -0
  47. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_gitrepo.py +0 -0
  48. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_pr_build.py +0 -0
  49. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_pr_labels.py +0 -0
  50. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_pr_markdown.py +0 -0
  51. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_testdb.py +0 -0
  52. {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.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.20.0
3
+ Version: 0.22.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
@@ -353,15 +353,48 @@ bdt commit "feat(x): add widget support" file1.py file2.py [--json] [--no-verify
353
353
  ```
354
354
 
355
355
  Stages, commits, and pushes the given files. Pre-flight checks: files
356
- exist, commit message looks like `type: description` (skip with
357
- `--skip-message-check`), not on `main`/`master` (skip with `--allow-main`).
358
- Retries once (re-`git add`) if a pre-commit hook reformats files. Pass
359
- `--subrepo <dir>` (repeatable) for repos that vendor a submodule (e.g.
360
- `database`) — files under that prefix are committed/pushed inside the
361
- submodule first, then the bump is staged in the parent repo.
356
+ exist, commit message follows [Conventional Commits](https://www.conventionalcommits.org)
357
+ (`type(scope): description`, skip with `--skip-message-check`), not on
358
+ `main`/`master` (skip with `--allow-main`). Retries once (re-`git add`) if a
359
+ pre-commit hook reformats files. Pass `--subrepo <dir>` (repeatable) for
360
+ repos that vendor a submodule (e.g. `database`) — files under that prefix
361
+ are committed/pushed inside the submodule first, then the bump is staged in
362
+ the parent repo.
363
+
364
+ The built-in commit types are `feat`, `fix`, `docs`, `style`, `refactor`,
365
+ `perf`, `test`, `build`, `ci`, `chore`, `revert`. A repo can accept
366
+ additional types on top of those under `[tool.bdt.commit]` in
367
+ `pyproject.toml`:
368
+
369
+ ```toml
370
+ [tool.bdt.commit]
371
+ types = ["sql", "infra"]
372
+ ```
373
+
374
+ Scope (the `(x)` in `feat(x): ...`) is unrestricted by default — any scope,
375
+ or none at all, is accepted. A repo can opt into restricting it to a fixed
376
+ list under the same table:
377
+
378
+ ```toml
379
+ [tool.bdt.commit]
380
+ scopes = ["api", "ui", "db"]
381
+ ```
382
+
383
+ Once configured, a message that *names* a scope must use one from the
384
+ list — but a message with no scope at all is still always accepted; this
385
+ doesn't make a scope mandatory.
386
+
387
+ If the pushed commit's type is `feat` and the current branch's PR is
388
+ already published (not a draft), it's converted back to draft — a feature
389
+ needs a fresh review pass before CI/merge, not just whatever review
390
+ happened before the commit existed. Run `bdt pr publish` when it's ready
391
+ again. Pass `--target`/`--pat` to resolve the PR on Azure DevOps (GitHub
392
+ always resolves the current branch's PR directly).
362
393
 
363
394
  Set `IS_BMS_AI_SANDBOX=1` to skip the push step (commit only) — used when
364
- an AI coding sandbox pushes on its own schedule separately.
395
+ an AI coding sandbox pushes on its own schedule separately. The draft
396
+ conversion above only runs after an actual push, so it's skipped in
397
+ sandbox mode too.
365
398
 
366
399
  `--json` emits a machine-readable result for AI-agent callers:
367
400
 
@@ -369,7 +402,7 @@ an AI coding sandbox pushes on its own schedule separately.
369
402
  {
370
403
  "success": true, "committed": true, "pushed": true,
371
404
  "message": "...", "files": ["..."], "commit_sha": "abc1234",
372
- "error": null, "hint": null
405
+ "error": null, "hint": null, "commit_type": "feat", "commit_scope": "x"
373
406
  }
374
407
  ```
375
408
 
@@ -342,15 +342,48 @@ bdt commit "feat(x): add widget support" file1.py file2.py [--json] [--no-verify
342
342
  ```
343
343
 
344
344
  Stages, commits, and pushes the given files. Pre-flight checks: files
345
- exist, commit message looks like `type: description` (skip with
346
- `--skip-message-check`), not on `main`/`master` (skip with `--allow-main`).
347
- Retries once (re-`git add`) if a pre-commit hook reformats files. Pass
348
- `--subrepo <dir>` (repeatable) for repos that vendor a submodule (e.g.
349
- `database`) — files under that prefix are committed/pushed inside the
350
- submodule first, then the bump is staged in the parent repo.
345
+ exist, commit message follows [Conventional Commits](https://www.conventionalcommits.org)
346
+ (`type(scope): description`, skip with `--skip-message-check`), not on
347
+ `main`/`master` (skip with `--allow-main`). Retries once (re-`git add`) if a
348
+ pre-commit hook reformats files. Pass `--subrepo <dir>` (repeatable) for
349
+ repos that vendor a submodule (e.g. `database`) — files under that prefix
350
+ are committed/pushed inside the submodule first, then the bump is staged in
351
+ the parent repo.
352
+
353
+ The built-in commit types are `feat`, `fix`, `docs`, `style`, `refactor`,
354
+ `perf`, `test`, `build`, `ci`, `chore`, `revert`. A repo can accept
355
+ additional types on top of those under `[tool.bdt.commit]` in
356
+ `pyproject.toml`:
357
+
358
+ ```toml
359
+ [tool.bdt.commit]
360
+ types = ["sql", "infra"]
361
+ ```
362
+
363
+ Scope (the `(x)` in `feat(x): ...`) is unrestricted by default — any scope,
364
+ or none at all, is accepted. A repo can opt into restricting it to a fixed
365
+ list under the same table:
366
+
367
+ ```toml
368
+ [tool.bdt.commit]
369
+ scopes = ["api", "ui", "db"]
370
+ ```
371
+
372
+ Once configured, a message that *names* a scope must use one from the
373
+ list — but a message with no scope at all is still always accepted; this
374
+ doesn't make a scope mandatory.
375
+
376
+ If the pushed commit's type is `feat` and the current branch's PR is
377
+ already published (not a draft), it's converted back to draft — a feature
378
+ needs a fresh review pass before CI/merge, not just whatever review
379
+ happened before the commit existed. Run `bdt pr publish` when it's ready
380
+ again. Pass `--target`/`--pat` to resolve the PR on Azure DevOps (GitHub
381
+ always resolves the current branch's PR directly).
351
382
 
352
383
  Set `IS_BMS_AI_SANDBOX=1` to skip the push step (commit only) — used when
353
- an AI coding sandbox pushes on its own schedule separately.
384
+ an AI coding sandbox pushes on its own schedule separately. The draft
385
+ conversion above only runs after an actual push, so it's skipped in
386
+ sandbox mode too.
354
387
 
355
388
  `--json` emits a machine-readable result for AI-agent callers:
356
389
 
@@ -358,7 +391,7 @@ an AI coding sandbox pushes on its own schedule separately.
358
391
  {
359
392
  "success": true, "committed": true, "pushed": true,
360
393
  "message": "...", "files": ["..."], "commit_sha": "abc1234",
361
- "error": null, "hint": null
394
+ "error": null, "hint": null, "commit_type": "feat", "commit_scope": "x"
362
395
  }
363
396
  ```
364
397
 
@@ -650,6 +650,12 @@ def find_repo_cmd(
650
650
  org: str | None = typer.Option(
651
651
  None, "--org", envvar=["AZDO_ORG", "BMS_ORG"], help="Azure DevOps org to search when there's no local match"
652
652
  ),
653
+ github_org: str | None = typer.Option(
654
+ None,
655
+ "--github-org",
656
+ envvar=["GITHUB_ORG", "BMS_GITHUB_ORG"],
657
+ help="GitHub org to search when there's no local match",
658
+ ),
653
659
  yes: bool = typer.Option(False, "--yes", help="Clone a remote-only match without an interactive confirmation prompt"),
654
660
  pat: str | None = typer.Option(
655
661
  None,
@@ -658,13 +664,15 @@ def find_repo_cmd(
658
664
  help="Azure DevOps PAT for cloning over HTTPS (else falls back to `az` login)",
659
665
  ),
660
666
  ) -> None:
661
- """Find a repo by name: locally first, then in an Azure DevOps org if there's no local match -- offering to clone it.
667
+ """Find a repo by name: locally first, then in an Azure DevOps org and/or a GitHub org if there's
668
+ no local match -- offering to clone it.
662
669
 
663
670
  Replaces the `cross-repo-discovery` skill's full `ALL_REPOS.md` org sync with a
664
671
  single-name lookup; it reads the same AZDO_WORK_DIR/BMS_WORK_DIR and AZDO_ORG/BMS_ORG
665
- env vars that skill used, so switching over needs no reconfiguration.
672
+ env vars that skill used, so switching over needs no reconfiguration. A GitHub-org match
673
+ clones into `root/github/<repo>`, by convention.
666
674
  """
667
- find_repo_mod.run(name, root=root, org=org, yes=yes, pat=pat)
675
+ find_repo_mod.run(name, root=root, org=org, github_org=github_org, yes=yes, pat=pat)
668
676
 
669
677
 
670
678
  @cleanup_app.command("worktrees")
@@ -738,6 +746,30 @@ def cleanup_worktree(
738
746
  )
739
747
 
740
748
 
749
+ def _draft_feat_pr(target: str, pat: str | None) -> None:
750
+ """A 'feat' commit landing on an already-published PR forces it back to draft --
751
+ a feature needs a fresh review pass before CI/merge, not just whatever review
752
+ happened before this commit existed.
753
+
754
+ Best-effort: swallows failures (no PR yet is the common case mid-implementation,
755
+ plus auth/network hiccups) rather than turning a successful commit+push into a
756
+ failure over a step that's purely a safety nudge.
757
+ """
758
+ try:
759
+ remote = current_remote()
760
+ if isinstance(remote, GitHubRemote):
761
+ converted = gh_pr.set_draft(require_gh())
762
+ else:
763
+ session = requests.Session()
764
+ session.headers.update(auth_header(pat))
765
+ pr = pr_build.get_pr(session, remote, current_branch(), target)
766
+ converted = pr_build.set_draft(session, remote, pr)
767
+ except (Exception, SystemExit):
768
+ return
769
+ if converted:
770
+ print("\n'feat' commit pushed -- PR converted back to draft (needs review before CI runs). Run `bdt pr publish` when ready.")
771
+
772
+
741
773
  @app.command()
742
774
  def commit(
743
775
  message: str,
@@ -747,6 +779,13 @@ def commit(
747
779
  subrepo: list[str] = typer.Option([], "--subrepo", help="Submodule directory name to split matching files into (repeatable)"),
748
780
  skip_message_check: bool = typer.Option(False, "--skip-message-check", help="Don't require a conventional-commit-style message"),
749
781
  allow_main: bool = typer.Option(False, "--allow-main", help="Allow committing directly on main/master"),
782
+ target: str = typer.Option("main", "--target", help="Target branch of the PR to draft on a 'feat' commit (Azure DevOps only)"),
783
+ pat: str | None = typer.Option(
784
+ None,
785
+ "--pat",
786
+ envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"],
787
+ help="Azure DevOps PAT (else falls back to `az` login)",
788
+ ),
750
789
  ) -> None:
751
790
  """Stage, commit, and push files, with pre-flight checks and a pre-commit-hook retry."""
752
791
  result = commit_mod.commit_and_push(
@@ -757,6 +796,8 @@ def commit(
757
796
  require_feature_branch=not allow_main,
758
797
  subrepos=subrepo,
759
798
  )
799
+ if result.success and result.pushed and result.extra.get("commit_type") == "feat":
800
+ _draft_feat_pr(target, pat)
760
801
  commit_mod.emit(result, use_json=json_output)
761
802
 
762
803
 
@@ -7,13 +7,67 @@ from __future__ import annotations
7
7
 
8
8
  import json
9
9
  import os
10
+ import re
10
11
  import shutil
11
12
  import subprocess
12
13
  import sys
13
14
  from dataclasses import dataclass, field
14
15
 
16
+ from .bdt_config import load_bdt_table
17
+
15
18
  IS_SANDBOX_ENV_VAR = "IS_BMS_AI_SANDBOX"
16
19
 
20
+ # Conventional Commits (https://www.conventionalcommits.org) type prefixes that are
21
+ # always accepted, regardless of what the calling repo configures.
22
+ BUILTIN_COMMIT_TYPES = {"feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"}
23
+
24
+ # `type(scope)!: description` -- scope and the breaking-change `!` are both optional.
25
+ _CONVENTIONAL_COMMIT_RE = re.compile(r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?!?: .+")
26
+
27
+
28
+ def allowed_commit_types(start=None) -> set[str]:
29
+ """`BUILTIN_COMMIT_TYPES` plus whatever a repo adds under `[tool.bdt.commit]`:
30
+
31
+ [tool.bdt.commit]
32
+ types = ["sql", "infra"]
33
+
34
+ lets a repo accept custom types (e.g. an "sql" type for a repo organized around
35
+ database migrations) on top of the standard set, rather than replacing it.
36
+ """
37
+ extra = load_bdt_table("commit", start).get("types", [])
38
+ if not isinstance(extra, list):
39
+ return set(BUILTIN_COMMIT_TYPES)
40
+ return BUILTIN_COMMIT_TYPES | {t for t in extra if isinstance(t, str)}
41
+
42
+
43
+ def allowed_commit_scopes(start=None) -> set[str]:
44
+ """`[tool.bdt.commit] scopes = [...]` -- opt-in, like `[tool.bdt.pr.required_labels]`:
45
+ unconfigured (the default, empty set) means any scope, or no scope at all, is
46
+ accepted. Configuring it only restricts which scope a message *may* name; a
47
+ message with no scope at all is still accepted either way -- this doesn't make
48
+ a scope mandatory.
49
+ """
50
+ raw = load_bdt_table("commit", start).get("scopes", [])
51
+ if not isinstance(raw, list):
52
+ return set()
53
+ return {s for s in raw if isinstance(s, str)}
54
+
55
+
56
+ def conventional_commit_type(message: str) -> str | None:
57
+ """The `type` prefix of `message` (e.g. "feat" from "feat(x): add widget"), or
58
+ None if `message` doesn't follow the `type(scope): description` shape at all.
59
+ """
60
+ match = _CONVENTIONAL_COMMIT_RE.match(message)
61
+ return match.group("type") if match else None
62
+
63
+
64
+ def conventional_commit_scope(message: str) -> str | None:
65
+ """The `scope` of `message` (e.g. "x" from "feat(x): add widget"), or None if
66
+ `message` doesn't follow the `type(scope): description` shape, or has no scope.
67
+ """
68
+ match = _CONVENTIONAL_COMMIT_RE.match(message)
69
+ return match.group("scope") if match else None
70
+
17
71
 
18
72
  def _run(cmd: list[str], cwd: str | None = None) -> subprocess.CompletedProcess:
19
73
  try:
@@ -204,12 +258,36 @@ def commit_and_push(
204
258
  warnings=warnings,
205
259
  )
206
260
 
207
- msg_ok = not require_message_quality or (len(message) >= 20 and ":" in message)
208
- if not check(msg_ok, "commit message quality (len>=20, has colon)"):
261
+ commit_type = conventional_commit_type(message)
262
+ allowed_types = allowed_commit_types()
263
+ msg_ok = not require_message_quality or commit_type in allowed_types
264
+ if not check(msg_ok, "commit message follows Conventional Commits (type(scope): description)"):
265
+ return CommitResult(
266
+ False, False, False, message, files,
267
+ error=f"Commit message doesn't follow Conventional Commits format 'type(scope): description' "
268
+ f"(allowed types: {', '.join(sorted(allowed_types))}) — got: {message!r}",
269
+ hint="Use e.g. 'feat(x): add widget' or 'fix: correct off-by-one'. Add repo-specific types under "
270
+ "[tool.bdt.commit] types = [...] in pyproject.toml.",
271
+ warnings=warnings,
272
+ )
273
+
274
+ commit_scope = conventional_commit_scope(message)
275
+ allowed_scopes = allowed_commit_scopes()
276
+ # Opt-in: unconfigured `allowed_scopes` means no restriction, and a message with
277
+ # no scope at all is always fine -- only a *named* scope outside the configured
278
+ # list is rejected. Matched case-insensitively, same as `pr_labels.missing_label_groups`.
279
+ scope_ok = (
280
+ not require_message_quality
281
+ or not allowed_scopes
282
+ or commit_scope is None
283
+ or commit_scope.lower() in {s.lower() for s in allowed_scopes}
284
+ )
285
+ if not check(scope_ok, "commit scope is in the allowed list"):
209
286
  return CommitResult(
210
287
  False, False, False, message, files,
211
- error=f"Commit message too short or missing type prefix (e.g. 'feat(x): ...') — got: {message!r}",
212
- hint="Use conventional commits format: 'feat(scope): description' or 'fix: description'",
288
+ error=f"Commit scope {commit_scope!r} isn't in the allowed list configured under "
289
+ f"[tool.bdt.commit] scopes (allowed: {', '.join(sorted(allowed_scopes))}) — got: {message!r}",
290
+ hint="Use one of the configured scopes, or omit the scope entirely.",
213
291
  warnings=warnings,
214
292
  )
215
293
 
@@ -266,8 +344,10 @@ def commit_and_push(
266
344
  committed = ok
267
345
  print(" ✓ committed", flush=True)
268
346
 
347
+ extra = {"commit_type": commit_type, "commit_scope": commit_scope}
348
+
269
349
  if _in_sandbox():
270
- return CommitResult(True, committed, False, message, files, commit_sha=_sha(), warnings=warnings)
350
+ return CommitResult(True, committed, False, message, files, commit_sha=_sha(), warnings=warnings, extra=extra)
271
351
 
272
352
  pr = _run(["git", "push"])
273
353
  if pr.returncode != 0:
@@ -277,10 +357,11 @@ def commit_and_push(
277
357
  error=(pr.stdout + pr.stderr).strip(),
278
358
  hint="Push rejected. Run `git pull --rebase`, resolve conflicts, then retry.",
279
359
  warnings=warnings,
360
+ extra=extra,
280
361
  )
281
362
 
282
363
  print(" ✓ pushed", flush=True)
283
- return CommitResult(True, committed, True, message, files, commit_sha=_sha(), warnings=warnings)
364
+ return CommitResult(True, committed, True, message, files, commit_sha=_sha(), warnings=warnings, extra=extra)
284
365
 
285
366
 
286
367
  def emit(result: CommitResult, *, use_json: bool) -> None:
@@ -1,6 +1,7 @@
1
1
  """`bdt find-repo <name>`: locate a repo by name, locally first and then in
2
- an Azure DevOps org, without needing the full `ALL_REPOS.md` org sync that
3
- the `cross-repo-discovery` skill (bmsuisse/skills) used to require.
2
+ an Azure DevOps org and/or a GitHub org, without needing the full
3
+ `ALL_REPOS.md` org sync that the `cross-repo-discovery` skill
4
+ (bmsuisse/skills) used to require.
4
5
 
5
6
  Env vars mirror that skill's so switching over needs no reconfiguration:
6
7
  - `AZDO_WORK_DIR` / `BMS_WORK_DIR`: local clone root to search (and to clone
@@ -8,6 +9,10 @@ Env vars mirror that skill's so switching over needs no reconfiguration:
8
9
  elsewhere.
9
10
  - `AZDO_ORG` / `BMS_ORG`: Azure DevOps org to search when no local match is
10
11
  found.
12
+ - `GITHUB_ORG` / `BMS_GITHUB_ORG`: GitHub org to search when no local match
13
+ is found. A match is cloned into `root/github/<repo>` -- GitHub repos
14
+ aren't split into ADO-style projects, so by convention they all land in
15
+ one `github` subfolder regardless of which org they came from.
11
16
  """
12
17
 
13
18
  from __future__ import annotations
@@ -16,14 +21,18 @@ import json
16
21
  import os
17
22
  import subprocess
18
23
  import sys
24
+ from collections.abc import Callable
19
25
  from concurrent.futures import ThreadPoolExecutor
20
26
  from dataclasses import dataclass
21
27
  from pathlib import Path
28
+ from typing import TypeVar
22
29
 
23
30
  from .ado_auth import auth_header
24
- from .cli_tools import require_az
31
+ from .cli_tools import require_az, require_gh
25
32
  from .worktree import find_repos
26
33
 
34
+ T = TypeVar("T")
35
+
27
36
 
28
37
  def work_dir() -> Path:
29
38
  env = os.environ.get("AZDO_WORK_DIR") or os.environ.get("BMS_WORK_DIR")
@@ -34,18 +43,21 @@ def work_dir() -> Path:
34
43
  return Path("~/projects").expanduser()
35
44
 
36
45
 
37
- def find_local(name: str, root: Path) -> list[Path]:
38
- """Repos under `root` whose folder name matches `name`, case-insensitively.
39
-
40
- Prefers exact matches; only falls back to substring matches (also
41
- case-insensitive) when no exact match exists, so a short/common name
42
- doesn't drown in unrelated results.
46
+ def _prefer_exact(items: list[T], name: str, key: Callable[[T], str]) -> list[T]:
47
+ """Exact (case-insensitive) matches on `key(item)` if there are any, else
48
+ substring matches -- shared by `find_local`/`find_remote`/`find_github`
49
+ (and, cross-source, by `run`) so a short/common name doesn't drown in
50
+ unrelated results.
43
51
  """
44
- repos = find_repos(root)
45
- exact = [r for r in repos if r.name.lower() == name.lower()]
52
+ exact = [i for i in items if key(i).lower() == name.lower()]
46
53
  if exact:
47
- return sorted(exact)
48
- return sorted(r for r in repos if name.lower() in r.name.lower())
54
+ return exact
55
+ return [i for i in items if name.lower() in key(i).lower()]
56
+
57
+
58
+ def find_local(name: str, root: Path) -> list[Path]:
59
+ """Repos under `root` whose folder name matches `name` -- see `_prefer_exact`."""
60
+ return sorted(_prefer_exact(find_repos(root), name, key=lambda r: r.name))
49
61
 
50
62
 
51
63
  @dataclass(frozen=True)
@@ -53,6 +65,7 @@ class RemoteRepo:
53
65
  project: str
54
66
  name: str
55
67
  remote_url: str
68
+ source: str = "ado"
56
69
 
57
70
 
58
71
  def _az(az: str, *args: str) -> tuple[bool, str]:
@@ -69,8 +82,7 @@ def _az(az: str, *args: str) -> tuple[bool, str]:
69
82
 
70
83
 
71
84
  def find_remote(az: str, org: str, name: str) -> list[RemoteRepo]:
72
- """Search every project in `org` for a repo matching `name`, the same
73
- exact-first/substring-fallback rule as `find_local`.
85
+ """Search every project in `org` for a repo matching `name` -- see `_prefer_exact`.
74
86
 
75
87
  Azure DevOps has no "search repos by name across the whole org" API, so
76
88
  (as the old skill's script did) this lists every project and then every
@@ -95,10 +107,23 @@ def find_remote(az: str, org: str, name: str) -> list[RemoteRepo]:
95
107
  for repos in pool.map(repos_for, projects):
96
108
  all_repos.extend(repos)
97
109
 
98
- exact = [r for r in all_repos if r.name.lower() == name.lower()]
99
- if exact:
100
- return exact
101
- return [r for r in all_repos if name.lower() in r.name.lower()]
110
+ return _prefer_exact(all_repos, name, key=lambda r: r.name)
111
+
112
+
113
+ def find_github(gh: str, org: str, name: str) -> list[RemoteRepo]:
114
+ """Search `org`'s GitHub repos for a match -- see `_prefer_exact`.
115
+
116
+ Unlike Azure DevOps, GitHub has a single flat list of repos per org/user
117
+ (no per-project split), so `gh repo list` covers the whole org in one call.
118
+ """
119
+ result = subprocess.run(
120
+ [gh, "repo", "list", org, "--json", "name,url", "--limit", "1000"], capture_output=True, text=True, check=False
121
+ )
122
+ if result.returncode != 0:
123
+ sys.exit(f"gh repo list failed (try `gh auth login`?):\n{result.stderr.strip()}")
124
+
125
+ all_repos = [RemoteRepo(org, r["name"], r["url"], source="github") for r in json.loads(result.stdout)]
126
+ return _prefer_exact(all_repos, name, key=lambda r: r.name)
102
127
 
103
128
 
104
129
  def clone(remote_url: str, dest: Path, auth: dict[str, str] | None = None) -> subprocess.CompletedProcess:
@@ -120,10 +145,27 @@ def clone(remote_url: str, dest: Path, auth: dict[str, str] | None = None) -> su
120
145
  sys.exit("'git' is required for this command but wasn't found on PATH.")
121
146
 
122
147
 
123
- def run(name: str, *, root: Path | None, org: str | None, yes: bool, pat: str | None = None) -> None:
148
+ def clone_github(gh: str, full_name: str, dest: Path) -> subprocess.CompletedProcess:
149
+ """Clone a GitHub repo via `gh repo clone`, so it authenticates the same way
150
+ `gh repo list` already did.
151
+
152
+ Deliberately not `clone()` + a hand-built header: `auth_header` (ADO's Basic-PAT-or-
153
+ `az`-Bearer-token helper) must never be sent to github.com, and `gh` already knows how
154
+ to authenticate its own git operations (private repos included).
155
+ """
156
+ try:
157
+ return subprocess.run([gh, "repo", "clone", full_name, str(dest)], check=False)
158
+ except FileNotFoundError:
159
+ sys.exit("'gh' is required for this command but wasn't found on PATH.")
160
+
161
+
162
+ def run(
163
+ name: str, *, root: Path | None, org: str | None, github_org: str | None = None, yes: bool, pat: str | None = None
164
+ ) -> None:
124
165
  """`bdt find-repo <name>`: search `root` (default `work_dir()`) for a local
125
- match, then -- if there isn't one -- `org`'s Azure DevOps repos, offering
126
- to clone a single remote-only match into `root/<project>/<repo>`.
166
+ match, then -- if there isn't one -- `org`'s Azure DevOps repos and/or
167
+ `github_org`'s GitHub repos, offering to clone a single remote-only match
168
+ into `root/<project>/<repo>` (ADO) or `root/github/<repo>` (GitHub).
127
169
  """
128
170
  search_root = root or work_dir()
129
171
  if not search_root.is_dir():
@@ -138,16 +180,38 @@ def run(name: str, *, root: Path | None, org: str | None, yes: bool, pat: str |
138
180
  print(path)
139
181
  return
140
182
 
141
- if not org:
183
+ if not org and not github_org:
142
184
  sys.exit(
143
185
  f"No local repo matching '{name}' found under {search_root}. "
144
- "Set AZDO_ORG (or BMS_ORG) to also search Azure DevOps, or pass --org."
186
+ "Set AZDO_ORG (or BMS_ORG) to also search Azure DevOps, GITHUB_ORG (or BMS_GITHUB_ORG) to "
187
+ "also search GitHub, or pass --org/--github-org."
145
188
  )
146
189
 
147
- az = require_az()
148
- remote_matches = find_remote(az, org, name)
190
+ # Both are independent, network-I/O-bound org searches -- run them concurrently
191
+ # rather than paying their full latency back-to-back when both are given.
192
+ remote_matches: list[RemoteRepo] = []
193
+ with ThreadPoolExecutor(max_workers=2) as pool:
194
+ searches = []
195
+ if org:
196
+ az = require_az()
197
+ searches.append(pool.submit(find_remote, az, org, name))
198
+ if github_org:
199
+ gh = require_gh()
200
+ searches.append(pool.submit(find_github, gh, github_org, name))
201
+ for search in searches:
202
+ remote_matches += search.result()
203
+
204
+ # An exact match from one source beats a substring match from the other --
205
+ # find_remote/find_github already apply exact-first within their own source.
206
+ remote_matches = _prefer_exact(remote_matches, name, key=lambda r: r.name)
207
+
149
208
  if not remote_matches:
150
- sys.exit(f"No repo matching '{name}' found locally under {search_root} or in the '{org}' Azure DevOps org.")
209
+ searched = []
210
+ if org:
211
+ searched.append(f"the '{org}' Azure DevOps org")
212
+ if github_org:
213
+ searched.append(f"the '{github_org}' GitHub org")
214
+ sys.exit(f"No repo matching '{name}' found locally under {search_root} or in " + " or ".join(searched) + ".")
151
215
 
152
216
  for r in remote_matches:
153
217
  print(f"{r.project}/{r.name} {r.remote_url}")
@@ -156,7 +220,7 @@ def run(name: str, *, root: Path | None, org: str | None, yes: bool, pat: str |
156
220
  sys.exit("\nMultiple matches -- narrow the name or clone manually.")
157
221
 
158
222
  match = remote_matches[0]
159
- dest = search_root / match.project / match.name
223
+ dest = search_root / "github" / match.name if match.source == "github" else search_root / match.project / match.name
160
224
  if dest.exists():
161
225
  sys.exit(f"{dest} already exists -- remove it or clone manually.")
162
226
 
@@ -172,6 +236,9 @@ def run(name: str, *, root: Path | None, org: str | None, yes: bool, pat: str |
172
236
  print("Not cloned.")
173
237
  return
174
238
 
175
- result = clone(match.remote_url, dest, auth_header(pat))
239
+ if match.source == "github":
240
+ result = clone_github(require_gh(), f"{match.project}/{match.name}", dest)
241
+ else:
242
+ result = clone(match.remote_url, dest, auth_header(pat))
176
243
  if result.returncode != 0:
177
244
  raise SystemExit(result.returncode)
@@ -499,6 +499,22 @@ def publish(gh: str) -> None:
499
499
  print("\nRun `bdt pr status --wait` to watch the PR's CI.")
500
500
 
501
501
 
502
+ def set_draft(gh: str) -> bool:
503
+ """Convert the current branch's ready PR back to a draft (`gh pr ready --undo`).
504
+
505
+ Returns True if it was just converted, False if it was already a draft --
506
+ a caller announcing "converted to draft" shouldn't do so for a PR that
507
+ already was one.
508
+ """
509
+ pr = get_pr(gh)
510
+ if pr.get("isDraft"):
511
+ return False
512
+ r = _run([gh, "pr", "ready", "--undo"], capture_output=True, encoding="utf-8")
513
+ if r.returncode != 0:
514
+ sys.exit((r.stderr or r.stdout).strip() or "`gh pr ready --undo` failed")
515
+ return True
516
+
517
+
502
518
  def _git(args: list[str], env: dict[str, str] | None = None, *, timeout: float = CLI_TIMEOUT_SECS) -> str:
503
519
  r = _run(["git", *args], capture_output=True, encoding="utf-8", env=env, timeout=timeout)
504
520
  if r.returncode != 0:
@@ -313,6 +313,19 @@ def publish(session: requests.Session, remote: AdoRemote, pr: dict) -> None:
313
313
  print("\nRun `bdt pr status --wait` to watch the PR's CI.")
314
314
 
315
315
 
316
+ def set_draft(session: requests.Session, remote: AdoRemote, pr: dict) -> bool:
317
+ """Convert a ready PR back to a draft (sets isDraft).
318
+
319
+ Returns True if it was just converted, False if it was already a draft --
320
+ a caller announcing "converted to draft" shouldn't do so for a PR that
321
+ already was one.
322
+ """
323
+ if pr.get("isDraft"):
324
+ return False
325
+ _patch_pr(session, remote, pr["pullRequestId"], {"isDraft": True})
326
+ return True
327
+
328
+
316
329
  def add_comment(session: requests.Session, remote: AdoRemote, pr_id: int, content: str) -> None:
317
330
  """Post a new top-level comment thread on the PR."""
318
331
  r = session.post(
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.20.0"
13
+ version = "0.22.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