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.
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/PKG-INFO +42 -9
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/README.md +41 -8
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/cli.py +44 -3
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/commit.py +87 -6
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/find_repo.py +96 -29
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/gh_pr.py +16 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/pr_build.py +13 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/pyproject.toml +1 -1
- bmsdna_devtools-0.22.0/tests/test_cli_commit.py +92 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_commit.py +183 -1
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_find_repo.py +157 -2
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_gh_pr.py +29 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_pr_build_flow.py +18 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/uv.lock +1 -1
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/.gitignore +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/.python-version +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/ado_issue.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/justfile +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.20.0 → bmsdna_devtools-0.22.0}/tests/test_testdb.py +0 -0
- {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.
|
|
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
|
|
357
|
-
`--skip-message-check`), not on
|
|
358
|
-
Retries once (re-`git add`) if a
|
|
359
|
-
`--subrepo <dir>` (repeatable) for
|
|
360
|
-
`database`) — files under that prefix
|
|
361
|
-
submodule first, then the bump is staged in
|
|
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
|
|
346
|
-
`--skip-message-check`), not on
|
|
347
|
-
Retries once (re-`git add`) if a
|
|
348
|
-
`--subrepo <dir>` (repeatable) for
|
|
349
|
-
`database`) — files under that prefix
|
|
350
|
-
submodule first, then the bump is staged in
|
|
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
|
|
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
|
-
|
|
208
|
-
|
|
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
|
|
212
|
-
|
|
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
|
|
3
|
-
the `cross-repo-discovery` skill
|
|
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
|
|
38
|
-
"""
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
return
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
|
126
|
-
to clone a single remote-only match
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|