bmsdna-devtools 0.46.0__tar.gz → 0.47.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.46.0 → bmsdna_devtools-0.47.0}/PKG-INFO +33 -4
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/README.md +32 -3
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/ado_issue.py +28 -6
- bmsdna_devtools-0.47.0/bmsdna/devtools/business_user.py +144 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/cli.py +28 -1
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/issue_show.py +13 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/skills/bmsdna-devtools/SKILL.md +8 -2
- bmsdna_devtools-0.47.0/tests/test_business_user.py +307 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_cli_issue.py +68 -1
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/uv.lock +1 -1
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/.gitignore +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/.python-version +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/_api_dump.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/agent_mentions.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/api_usage.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/azure_arm.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/azure_auth.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/dead_code.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/env_files.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/find_injection.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/gh_args.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/github_api.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/issue_do.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/issue_watch.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/lint_sql.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/lint_sql_files.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/pr_choice.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/pr_create_args.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/pr_info.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/pr_ref.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/translate.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/usage/__init__.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/usage/cli.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/usage/pipeline.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/usage/pricing.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/usage/report.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/usage/sql/00_schema.sql +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/usage/sql/01_ingest_claude.sql +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/usage/sql/01_ingest_codex.sql +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/usage/sql/02_messages.sql +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/usage/sql/03_markers.sql +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/usage/sql/04_facts.sql +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/usage/sql/05_views.sql +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/justfile +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/fake_github.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_agent_mentions.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_api_usage.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_azure_arm.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_cli_version.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_env_files.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_find_injection.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_github_api.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_issue_do.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_issue_for_me.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_issue_show.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_issue_watch.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_lint_sql.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_lint_sql_files.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_logs.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_pr_build_create_options.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_pr_choice.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_pr_create_args.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_pr_description_not_truncated.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_pr_info.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_translate.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_usage.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.0}/tests/test_wait_timeout.py +0 -0
- {bmsdna_devtools-0.46.0 → bmsdna_devtools-0.47.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.47.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
|
|
@@ -19,7 +19,7 @@ Description-Content-Type: text/markdown
|
|
|
19
19
|
# bmsdna-devtools
|
|
20
20
|
|
|
21
21
|
Shared developer tooling for BMS projects: PR build/check status and a
|
|
22
|
-
one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, finding issues addressed to an agent (`bdt issue search --for-me`) and waiting for new comments (`bdt issue watch`), git worktrees (creation and
|
|
22
|
+
one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, finding issues addressed to an agent (`bdt issue search --for-me`), finding issues filed by business users (`bdt issue search --business-user`) and waiting for new comments (`bdt issue watch`), git worktrees (creation and
|
|
23
23
|
merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
|
|
24
24
|
pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
|
|
25
25
|
postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
@@ -333,8 +333,10 @@ adds/edits/deletes comments on one, auto-detected from `origin` like `bdt pr
|
|
|
333
333
|
`issue show NUMBER` prints one issue / work item: state, title, URL, description, tags, links to other
|
|
334
334
|
issues (parent, children, blocked by, blocks; related on Azure DevOps), the Azure DevOps Type / Area /
|
|
335
335
|
Iteration / Assigned to (GitHub: assignees, milestone) and all comments (Azure DevOps HTML is shown as plain
|
|
336
|
-
text).
|
|
337
|
-
|
|
336
|
+
text). On Azure DevOps it also prints `Created by:` with whether the creator is a business user (see
|
|
337
|
+
[`bdt issue search --business-user`](#bdt-issue-search---business-user)). `--json` prints the same as one JSON
|
|
338
|
+
document (`number, title, state, url, body, tags, fields, parent, children, blocked_by, blocks, related,
|
|
339
|
+
comments, created_by, business_user, business_user_reason`; the last three are `null` on GitHub).
|
|
338
340
|
|
|
339
341
|
`--parent ID` and `--blocked-by ID` (repeatable) on `create` and `update` link the item to its parent and to
|
|
340
342
|
the issues / work items that block it, on both backends. On `update` only the links that are missing are
|
|
@@ -536,6 +538,33 @@ bdt issue watch 84 --since-comment 123 --skip-own --json --timeout 1800
|
|
|
536
538
|
- A single failed poll (network blip, API error) is retried; three in a row abort with the error.
|
|
537
539
|
- On Azure DevOps only the newest 100 comments of a work item are looked at per poll.
|
|
538
540
|
|
|
541
|
+
## `bdt issue search --business-user`
|
|
542
|
+
|
|
543
|
+
Business users file work items too, often with vague wording, and those need a different handling than a
|
|
544
|
+
developer's. `--business-user` (Azure DevOps only) keeps the work items **created by a business user**:
|
|
545
|
+
|
|
546
|
+
```bash
|
|
547
|
+
bdt issue search --business-user [--commit-depth 1000] [--tag needs-techie] [--for-me] [--state open] ...
|
|
548
|
+
bdt issue show 1234 # also prints: Created by: Sam Sales (business user: Stakeholder license)
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
A creator is a business user when
|
|
552
|
+
|
|
553
|
+
1. their license is **Stakeholder** (Member Entitlement API; needs a PAT with the *Member Entitlement
|
|
554
|
+
Management (read)* scope), or
|
|
555
|
+
2. they authored **none of the last `--commit-depth` commits** (default 1000) of the repo you run `bdt` in. This
|
|
556
|
+
also decides when the license can't be read (missing PAT scope, ...) -- the license is then "unknown"; it is
|
|
557
|
+
not an error.
|
|
558
|
+
|
|
559
|
+
The commit check matches the creator's email, else display name (a heuristic: two people sharing a name are
|
|
560
|
+
taken for one), against the authors and committers of those commits (`.mailmap` applied). Identities that are
|
|
561
|
+
not a person (build service, service accounts: no email as `uniqueName`) stay "unknown". A developer whose last commit is older than that window, or who works in other
|
|
562
|
+
repos, counts as a business user here. A shallow clone with less history than `--commit-depth` can't say who never
|
|
563
|
+
committed, so the creator stays "unknown" and is not returned by `--business-user`. `issue show` prints the reason
|
|
564
|
+
next to the creator -- `business user`, `developer` or `unknown` -- and `--json` has `created_by`, `business_user`
|
|
565
|
+
and `business_user_reason`. `--business-user` can't be combined with `--org-wide` (the commit check is about this
|
|
566
|
+
repo's history).
|
|
567
|
+
|
|
539
568
|
## `bdt env get-keys`
|
|
540
569
|
|
|
541
570
|
Lists the variable **names** (never the values) defined in `.env`, `*.env` and `.env.*` files under the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bmsdna-devtools
|
|
2
2
|
|
|
3
3
|
Shared developer tooling for BMS projects: PR build/check status and a
|
|
4
|
-
one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, finding issues addressed to an agent (`bdt issue search --for-me`) and waiting for new comments (`bdt issue watch`), git worktrees (creation and
|
|
4
|
+
one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, finding issues addressed to an agent (`bdt issue search --for-me`), finding issues filed by business users (`bdt issue search --business-user`) and waiting for new comments (`bdt issue watch`), git worktrees (creation and
|
|
5
5
|
merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
|
|
6
6
|
pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
|
|
7
7
|
postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
@@ -315,8 +315,10 @@ adds/edits/deletes comments on one, auto-detected from `origin` like `bdt pr
|
|
|
315
315
|
`issue show NUMBER` prints one issue / work item: state, title, URL, description, tags, links to other
|
|
316
316
|
issues (parent, children, blocked by, blocks; related on Azure DevOps), the Azure DevOps Type / Area /
|
|
317
317
|
Iteration / Assigned to (GitHub: assignees, milestone) and all comments (Azure DevOps HTML is shown as plain
|
|
318
|
-
text).
|
|
319
|
-
|
|
318
|
+
text). On Azure DevOps it also prints `Created by:` with whether the creator is a business user (see
|
|
319
|
+
[`bdt issue search --business-user`](#bdt-issue-search---business-user)). `--json` prints the same as one JSON
|
|
320
|
+
document (`number, title, state, url, body, tags, fields, parent, children, blocked_by, blocks, related,
|
|
321
|
+
comments, created_by, business_user, business_user_reason`; the last three are `null` on GitHub).
|
|
320
322
|
|
|
321
323
|
`--parent ID` and `--blocked-by ID` (repeatable) on `create` and `update` link the item to its parent and to
|
|
322
324
|
the issues / work items that block it, on both backends. On `update` only the links that are missing are
|
|
@@ -518,6 +520,33 @@ bdt issue watch 84 --since-comment 123 --skip-own --json --timeout 1800
|
|
|
518
520
|
- A single failed poll (network blip, API error) is retried; three in a row abort with the error.
|
|
519
521
|
- On Azure DevOps only the newest 100 comments of a work item are looked at per poll.
|
|
520
522
|
|
|
523
|
+
## `bdt issue search --business-user`
|
|
524
|
+
|
|
525
|
+
Business users file work items too, often with vague wording, and those need a different handling than a
|
|
526
|
+
developer's. `--business-user` (Azure DevOps only) keeps the work items **created by a business user**:
|
|
527
|
+
|
|
528
|
+
```bash
|
|
529
|
+
bdt issue search --business-user [--commit-depth 1000] [--tag needs-techie] [--for-me] [--state open] ...
|
|
530
|
+
bdt issue show 1234 # also prints: Created by: Sam Sales (business user: Stakeholder license)
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
A creator is a business user when
|
|
534
|
+
|
|
535
|
+
1. their license is **Stakeholder** (Member Entitlement API; needs a PAT with the *Member Entitlement
|
|
536
|
+
Management (read)* scope), or
|
|
537
|
+
2. they authored **none of the last `--commit-depth` commits** (default 1000) of the repo you run `bdt` in. This
|
|
538
|
+
also decides when the license can't be read (missing PAT scope, ...) -- the license is then "unknown"; it is
|
|
539
|
+
not an error.
|
|
540
|
+
|
|
541
|
+
The commit check matches the creator's email, else display name (a heuristic: two people sharing a name are
|
|
542
|
+
taken for one), against the authors and committers of those commits (`.mailmap` applied). Identities that are
|
|
543
|
+
not a person (build service, service accounts: no email as `uniqueName`) stay "unknown". A developer whose last commit is older than that window, or who works in other
|
|
544
|
+
repos, counts as a business user here. A shallow clone with less history than `--commit-depth` can't say who never
|
|
545
|
+
committed, so the creator stays "unknown" and is not returned by `--business-user`. `issue show` prints the reason
|
|
546
|
+
next to the creator -- `business user`, `developer` or `unknown` -- and `--json` has `created_by`, `business_user`
|
|
547
|
+
and `business_user_reason`. `--business-user` can't be combined with `--org-wide` (the commit check is about this
|
|
548
|
+
repo's history).
|
|
549
|
+
|
|
521
550
|
## `bdt env get-keys`
|
|
522
551
|
|
|
523
552
|
Lists the variable **names** (never the values) defined in `.env`, `*.env` and `.env.*` files under the
|
|
@@ -50,6 +50,7 @@ from urllib.parse import quote
|
|
|
50
50
|
import requests
|
|
51
51
|
|
|
52
52
|
from .bdt_config import load_bdt_table
|
|
53
|
+
from .business_user import BusinessUserDetector
|
|
53
54
|
from .cli_tools import ensure_agent_session_note
|
|
54
55
|
from .gitrepo import AdoRemote
|
|
55
56
|
from .issue_show import IssueDetails
|
|
@@ -324,9 +325,12 @@ def get_work_item(session: requests.Session, remote: AdoRemote, work_item_id: in
|
|
|
324
325
|
return r.json()
|
|
325
326
|
|
|
326
327
|
|
|
327
|
-
def get_details(
|
|
328
|
+
def get_details(
|
|
329
|
+
session: requests.Session, remote: AdoRemote, work_item_id: int, business_users: BusinessUserDetector | None = None
|
|
330
|
+
) -> IssueDetails:
|
|
328
331
|
"""What `bdt issue show` prints for a work item: its fields, links and comments. Long text
|
|
329
|
-
(Description, comments) is HTML as stored; it is shown as plain text.
|
|
332
|
+
(Description, comments) is HTML as stored; it is shown as plain text. With `business_users`, also who
|
|
333
|
+
created it and whether that is a business user."""
|
|
330
334
|
work_item = get_work_item(session, remote, work_item_id)
|
|
331
335
|
fields = work_item.get("fields", {})
|
|
332
336
|
relations = work_item.get("relations") or []
|
|
@@ -338,6 +342,8 @@ def get_details(session: requests.Session, remote: AdoRemote, work_item_id: int)
|
|
|
338
342
|
if isinstance(assigned_to, dict):
|
|
339
343
|
assigned_to = assigned_to.get("displayName")
|
|
340
344
|
parents = linked(PARENT_REL)
|
|
345
|
+
creator = fields.get("System.CreatedBy")
|
|
346
|
+
verdict = business_users.verdict(creator) if business_users else None
|
|
341
347
|
return IssueDetails(
|
|
342
348
|
number=work_item_id,
|
|
343
349
|
title=fields.get("System.Title", ""),
|
|
@@ -357,6 +363,9 @@ def get_details(session: requests.Session, remote: AdoRemote, work_item_id: int)
|
|
|
357
363
|
blocks=linked(BLOCKS_REL),
|
|
358
364
|
related=linked(RELATED_REL),
|
|
359
365
|
comments=to_comments(list_all_comments(session, remote, work_item_id), is_html=True),
|
|
366
|
+
created_by=creator.get("displayName") if isinstance(creator, dict) else None,
|
|
367
|
+
business_user=verdict.business_user if verdict else None,
|
|
368
|
+
business_user_reason=verdict.reason if verdict else None,
|
|
360
369
|
)
|
|
361
370
|
|
|
362
371
|
|
|
@@ -700,7 +709,9 @@ def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int,
|
|
|
700
709
|
return [wi["id"] for wi in r.json()["workItems"]]
|
|
701
710
|
|
|
702
711
|
|
|
703
|
-
def get_work_items(
|
|
712
|
+
def get_work_items(
|
|
713
|
+
session: requests.Session, remote: AdoRemote, ids: list[int], org_wide: bool = False, with_creator: bool = False
|
|
714
|
+
) -> list[dict]:
|
|
704
715
|
"""Batch-fetch Title/State (plus TeamProject when `org_wide`) for a set of work item ids, in
|
|
705
716
|
the given `ids` order — WIQL only returns ids (not field values), and this batch endpoint
|
|
706
717
|
doesn't guarantee it echoes them back in the order they were requested, so the caller's WIQL
|
|
@@ -708,12 +719,14 @@ def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int],
|
|
|
708
719
|
|
|
709
720
|
`org_wide` fetches via the org-level endpoint (ids from an org-wide `run_wiql` may belong to
|
|
710
721
|
a project other than `remote.project`) and also asks for `System.TeamProject`, so `search()`
|
|
711
|
-
can show which project each result came from.
|
|
722
|
+
can show which project each result came from. `with_creator` also asks for `System.CreatedBy`.
|
|
712
723
|
"""
|
|
713
724
|
if not ids:
|
|
714
725
|
return []
|
|
715
726
|
base = _org_url(remote) if org_wide else _base_url(remote)
|
|
716
727
|
fields = "System.Title,System.State,System.TeamProject" if org_wide else "System.Title,System.State"
|
|
728
|
+
if with_creator:
|
|
729
|
+
fields += ",System.CreatedBy"
|
|
717
730
|
r = session.get(
|
|
718
731
|
f"{base}/_apis/wit/workitems",
|
|
719
732
|
params={"ids": ",".join(map(str, ids)), "fields": fields, "api-version": "7.1"},
|
|
@@ -756,6 +769,7 @@ def search(
|
|
|
756
769
|
tags: list[str] | None = None,
|
|
757
770
|
comment_filter: Callable[[str | None], bool] | None = None,
|
|
758
771
|
scan_limit: int | None = None,
|
|
772
|
+
business_users: BusinessUserDetector | None = None,
|
|
759
773
|
) -> list[dict]:
|
|
760
774
|
"""Search (or, with no keywords, just list) work items by keywords (ANDed, matched against
|
|
761
775
|
Title or Description) and state, most recently changed first. `board` is an Azure Boards team
|
|
@@ -771,13 +785,21 @@ def search(
|
|
|
771
785
|
e.g. `bdt issue search --for-me`. WIQL can't query comments, so up to `_COMMENT_FILTER_POOL`
|
|
772
786
|
candidates are fetched and checked one by one, stopping once `top` have matched. `scan_limit` sizes that
|
|
773
787
|
pool as if it were `top` (default: `top`), for a caller that wants few results out of a wide scan.
|
|
788
|
+
|
|
789
|
+
`business_users` keeps only work items created by a business user (see `business_user.py`), checked before
|
|
790
|
+
`comment_filter` since it costs no request per work item.
|
|
774
791
|
"""
|
|
775
792
|
if org_wide and board:
|
|
776
793
|
sys.exit("--board can't be combined with --org-wide (a board's Area Path is scoped to a single project).")
|
|
777
794
|
area_path = get_team_area_path(session, remote, board) if board else None
|
|
778
795
|
wiql = build_search_wiql(keywords, since, area_path, state, org_wide=org_wide, tags=tags)
|
|
779
|
-
|
|
780
|
-
|
|
796
|
+
filtered = comment_filter or business_users
|
|
797
|
+
ids = run_wiql(session, remote, wiql, min((scan_limit or top) * 10, _COMMENT_FILTER_POOL) if filtered else top, org_wide=org_wide)
|
|
798
|
+
items = get_work_items(session, remote, ids, org_wide=org_wide, with_creator=business_users is not None)
|
|
799
|
+
if business_users:
|
|
800
|
+
items = [i for i in items if business_users.verdict(i["fields"].get("System.CreatedBy")).business_user]
|
|
801
|
+
if not comment_filter:
|
|
802
|
+
items = items[:top]
|
|
781
803
|
if comment_filter:
|
|
782
804
|
items = _filter_by_last_comment(session, remote, items, top, comment_filter)
|
|
783
805
|
for item in items:
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Is the creator of an Azure DevOps work item a business user (as opposed to a developer)?
|
|
2
|
+
|
|
3
|
+
Backs `bdt issue search --business-user` and the `Created by` line of `bdt issue show`. A creator is a
|
|
4
|
+
business user when
|
|
5
|
+
|
|
6
|
+
1. their Azure DevOps license is `Stakeholder` (Member Entitlement API), or
|
|
7
|
+
2. they authored none of the last `DEFAULT_COMMIT_DEPTH` commits of the local repo.
|
|
8
|
+
|
|
9
|
+
The entitlement API needs a PAT with the "Member Entitlement Management (read)" scope; when it is not
|
|
10
|
+
reachable the license is treated as unknown and only the commit check decides.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import subprocess
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from functools import cached_property
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from urllib.parse import quote
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
from .gitrepo import AdoRemote
|
|
25
|
+
|
|
26
|
+
DEFAULT_COMMIT_DEPTH = 1000
|
|
27
|
+
STAKEHOLDER = "stakeholder"
|
|
28
|
+
_ENTITLEMENT_API_VERSION = "7.1-preview.3"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Verdict:
|
|
33
|
+
"""`business_user` is None when neither check could tell (no license info, no usable git history)."""
|
|
34
|
+
|
|
35
|
+
business_user: bool | None
|
|
36
|
+
reason: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class CommitAuthors:
|
|
41
|
+
"""Lowercased emails and names of the authors and committers of the last `depth` commits."""
|
|
42
|
+
|
|
43
|
+
emails: frozenset[str]
|
|
44
|
+
names: frozenset[str]
|
|
45
|
+
depth: int
|
|
46
|
+
|
|
47
|
+
def contains(self, email: str | None, name: str | None) -> bool:
|
|
48
|
+
return bool(email and email.casefold() in self.emails) or bool(name and name.casefold() in self.names)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_commit_authors(depth: int = DEFAULT_COMMIT_DEPTH, cwd: Path | None = None) -> CommitAuthors | None:
|
|
52
|
+
"""The authors of the last `depth` commits of the repo at `cwd`, or None when that history can't tell who never
|
|
53
|
+
committed: not a git repo, or a shallow clone that has fewer than `depth` commits."""
|
|
54
|
+
# %aE/%aN/%cE/%cN honour .mailmap; \x1f can't occur in a name, and a name can't hold a newline
|
|
55
|
+
log = subprocess.run(
|
|
56
|
+
["git", "log", "-n", str(depth), "--format=%aE%x1f%aN%x1f%cE%x1f%cN"],
|
|
57
|
+
capture_output=True,
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
errors="replace",
|
|
60
|
+
cwd=cwd,
|
|
61
|
+
check=False,
|
|
62
|
+
)
|
|
63
|
+
if log.returncode != 0:
|
|
64
|
+
return None
|
|
65
|
+
rows = [fields for line in log.stdout.split("\n") if len(fields := line.split("\x1f")) == 4]
|
|
66
|
+
# `--is-shallow-repository` needs git 2.15; the shallow file's presence works everywhere
|
|
67
|
+
shallow_file = subprocess.run(["git", "rev-parse", "--git-path", "shallow"], capture_output=True, text=True, cwd=cwd, check=False)
|
|
68
|
+
if shallow_file.returncode == 0 and (cwd or Path()).joinpath(shallow_file.stdout.strip()).exists() and len(rows) < depth:
|
|
69
|
+
return None
|
|
70
|
+
return CommitAuthors(
|
|
71
|
+
emails=frozenset(v.casefold() for row in rows for v in (row[0], row[2]) if v),
|
|
72
|
+
names=frozenset(v.casefold() for row in rows for v in (row[1], row[3]) if v),
|
|
73
|
+
depth=depth,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_license(session: requests.Session, remote: AdoRemote, identity_id: str) -> str | None:
|
|
78
|
+
"""The account license type of an identity, lowercased (`stakeholder`, `express`, `advanced`, `none`, ...), or None
|
|
79
|
+
when the entitlement API doesn't answer (missing PAT scope, user not in the org, outage, ...): the license is
|
|
80
|
+
optional enrichment, and the commit check still has a say without it."""
|
|
81
|
+
try:
|
|
82
|
+
r = session.get(
|
|
83
|
+
f"https://vsaex.dev.azure.com/{remote.org}/_apis/userentitlements/{quote(identity_id, safe='')}",
|
|
84
|
+
params={"api-version": _ENTITLEMENT_API_VERSION},
|
|
85
|
+
)
|
|
86
|
+
except requests.RequestException:
|
|
87
|
+
return None
|
|
88
|
+
if r.status_code != 200:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
body = r.json()
|
|
92
|
+
except ValueError: # e.g. an HTML sign-in page for a PAT that may not use this API
|
|
93
|
+
return None
|
|
94
|
+
license_type = ((body.get("accessLevel") if isinstance(body, dict) else None) or {}).get("accountLicenseType")
|
|
95
|
+
return license_type.casefold() if isinstance(license_type, str) else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def classify(
|
|
99
|
+
license_type: str | None, read_authors: Callable[[], CommitAuthors | None], email: str | None, name: str | None
|
|
100
|
+
) -> Verdict:
|
|
101
|
+
"""Stakeholder license -> business user; else, no commit by this person in `read_authors()` (only called when the
|
|
102
|
+
license doesn't settle it) -> business user."""
|
|
103
|
+
if license_type == STAKEHOLDER:
|
|
104
|
+
return Verdict(True, "Stakeholder license")
|
|
105
|
+
authors = read_authors()
|
|
106
|
+
known_license = f"{license_type} license" if license_type else "license unknown"
|
|
107
|
+
if authors is None:
|
|
108
|
+
return Verdict(None, f"{known_license}, commit history unavailable")
|
|
109
|
+
if authors.contains(email, name):
|
|
110
|
+
return Verdict(False, f"{known_license}, has commits")
|
|
111
|
+
return Verdict(True, f"{known_license}, no commits in the last {authors.depth} of this repo")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class BusinessUserDetector:
|
|
115
|
+
"""Judges work item creators (the `System.CreatedBy` identity dict), looking up each license once and reading
|
|
116
|
+
the git history of the current directory's repo once per detector."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, session: requests.Session, remote: AdoRemote, commit_depth: int = DEFAULT_COMMIT_DEPTH) -> None:
|
|
119
|
+
self._session = session
|
|
120
|
+
self._remote = remote
|
|
121
|
+
self._commit_depth = commit_depth
|
|
122
|
+
self._licenses: dict[str, str | None] = {}
|
|
123
|
+
|
|
124
|
+
@cached_property
|
|
125
|
+
def _commit_authors(self) -> CommitAuthors | None:
|
|
126
|
+
return read_commit_authors(self._commit_depth)
|
|
127
|
+
|
|
128
|
+
def _license(self, identity_id: str | None) -> str | None:
|
|
129
|
+
if not identity_id:
|
|
130
|
+
return None
|
|
131
|
+
if identity_id not in self._licenses:
|
|
132
|
+
self._licenses[identity_id] = get_license(self._session, self._remote, identity_id)
|
|
133
|
+
return self._licenses[identity_id]
|
|
134
|
+
|
|
135
|
+
def verdict(self, creator: dict | None) -> Verdict:
|
|
136
|
+
"""`creator` is the `System.CreatedBy` field: an identity dict (`id`, `uniqueName`, `displayName`)."""
|
|
137
|
+
if not isinstance(creator, dict):
|
|
138
|
+
return Verdict(None, "creator unknown")
|
|
139
|
+
if "@" not in (creator.get("uniqueName") or ""):
|
|
140
|
+
# build service, service accounts, ...: no license, no commits, and not someone to ask questions
|
|
141
|
+
return Verdict(None, "not a person")
|
|
142
|
+
identity_id = creator.get("id")
|
|
143
|
+
license_type = self._license(identity_id if isinstance(identity_id, str) else None)
|
|
144
|
+
return classify(license_type, lambda: self._commit_authors, creator.get("uniqueName"), creator.get("displayName"))
|
|
@@ -27,6 +27,7 @@ from . import find_repo as find_repo_mod
|
|
|
27
27
|
from . import issue_do as issue_do_mod
|
|
28
28
|
from . import issue_watch as issue_watch_mod
|
|
29
29
|
from . import gh_issue, gh_pr
|
|
30
|
+
from . import business_user as business_user_mod
|
|
30
31
|
from . import issue_show as issue_show_mod
|
|
31
32
|
from . import find_injection as find_injection_mod
|
|
32
33
|
from . import lint as lint_mod
|
|
@@ -926,6 +927,19 @@ def issue_search(
|
|
|
926
927
|
envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"],
|
|
927
928
|
help="Azure DevOps PAT (else uses DefaultAzureCredential, e.g. your `az login`)",
|
|
928
929
|
),
|
|
930
|
+
business_user: bool = typer.Option(
|
|
931
|
+
False,
|
|
932
|
+
"--business-user",
|
|
933
|
+
help="Azure DevOps only: only work items created by a business user -- someone with a Stakeholder license, or "
|
|
934
|
+
"(when the license can't be read, or isn't Stakeholder) with no commit in the last --commit-depth commits of "
|
|
935
|
+
"this repo. The license lookup needs a PAT with the 'Member Entitlement Management (read)' scope.",
|
|
936
|
+
),
|
|
937
|
+
commit_depth: int = typer.Option(
|
|
938
|
+
business_user_mod.DEFAULT_COMMIT_DEPTH,
|
|
939
|
+
"--commit-depth",
|
|
940
|
+
min=1,
|
|
941
|
+
help="With --business-user: how many of the latest commits of this repo count when checking whether a creator ever committed",
|
|
942
|
+
),
|
|
929
943
|
for_me: bool = typer.Option(
|
|
930
944
|
False,
|
|
931
945
|
"--for-me",
|
|
@@ -968,6 +982,10 @@ def issue_search(
|
|
|
968
982
|
"""
|
|
969
983
|
if state not in ("open", "closed", "all"):
|
|
970
984
|
raise typer.BadParameter("Must be one of: open, closed, all", param_hint="--state")
|
|
985
|
+
if business_user and org_wide:
|
|
986
|
+
raise typer.BadParameter("--business-user checks the commits of this repo, so it can't be combined with --org-wide", param_hint="--business-user")
|
|
987
|
+
if commit_depth != business_user_mod.DEFAULT_COMMIT_DEPTH and not business_user:
|
|
988
|
+
raise typer.BadParameter("--commit-depth only works together with --business-user", param_hint="--commit-depth")
|
|
971
989
|
if take and not for_me:
|
|
972
990
|
raise typer.BadParameter("--take only works together with --for-me", param_hint="--take")
|
|
973
991
|
if take and limit < 1:
|
|
@@ -1010,9 +1028,15 @@ def issue_search(
|
|
|
1010
1028
|
else:
|
|
1011
1029
|
remote = current_remote()
|
|
1012
1030
|
|
|
1031
|
+
if business_user and isinstance(remote, GitHubRemote):
|
|
1032
|
+
raise typer.BadParameter("--business-user is only available for Azure DevOps work items", param_hint="--business-user")
|
|
1033
|
+
# one detector for all polls of --wait: it caches the git history and the licenses it asked for
|
|
1034
|
+
detector: business_user_mod.BusinessUserDetector | None = None
|
|
1035
|
+
|
|
1013
1036
|
def search_once() -> tuple[list[dict], tuple[AdoRemote | GitHubRemote, int] | None]:
|
|
1014
1037
|
"""One search (the backend prints the matches, or "No matching ...", itself); with the first match's
|
|
1015
1038
|
remote and number, which is where `--take` claims it."""
|
|
1039
|
+
nonlocal detector
|
|
1016
1040
|
if isinstance(remote, GitHubRemote):
|
|
1017
1041
|
resolved_board = gh_issue.resolve_board(board)
|
|
1018
1042
|
items = gh_issue.search(
|
|
@@ -1023,6 +1047,9 @@ def issue_search(
|
|
|
1023
1047
|
session = requests.Session()
|
|
1024
1048
|
session.headers.update(auth_header(pat))
|
|
1025
1049
|
resolved_board = ado_issue.resolve_board(board)
|
|
1050
|
+
if business_user:
|
|
1051
|
+
detector = detector or business_user_mod.BusinessUserDetector(session, remote, commit_depth)
|
|
1052
|
+
search_extra["business_users"] = detector
|
|
1026
1053
|
items = ado_issue.search(
|
|
1027
1054
|
session, remote, keywords or [], since=since, board=resolved_board, top=limit, state=state, org_wide=org_wide, tags=tags_or_labels, **search_extra
|
|
1028
1055
|
)
|
|
@@ -1343,7 +1370,7 @@ def issue_show(
|
|
|
1343
1370
|
else:
|
|
1344
1371
|
session = requests.Session()
|
|
1345
1372
|
session.headers.update(auth_header(pat))
|
|
1346
|
-
details = ado_issue.get_details(session, remote, number)
|
|
1373
|
+
details = ado_issue.get_details(session, remote, number, business_user_mod.BusinessUserDetector(session, remote))
|
|
1347
1374
|
typer.echo(issue_show_mod.format_details(details, as_json=as_json))
|
|
1348
1375
|
|
|
1349
1376
|
|
|
@@ -33,18 +33,31 @@ class IssueDetails:
|
|
|
33
33
|
blocks: list[int] = field(default_factory=list)
|
|
34
34
|
related: list[int] = field(default_factory=list)
|
|
35
35
|
comments: list[Comment] = field(default_factory=list)
|
|
36
|
+
# Azure DevOps with a business-user check: who created it, and whether that is a business user (None = unknown)
|
|
37
|
+
created_by: str | None = None
|
|
38
|
+
business_user: bool | None = None
|
|
39
|
+
business_user_reason: str | None = None
|
|
36
40
|
|
|
37
41
|
|
|
38
42
|
def _refs(numbers: list[int]) -> str:
|
|
39
43
|
return ", ".join(f"#{n}" for n in numbers)
|
|
40
44
|
|
|
41
45
|
|
|
46
|
+
def _creator_note(details: IssueDetails) -> str:
|
|
47
|
+
if details.business_user_reason is None:
|
|
48
|
+
return ""
|
|
49
|
+
kind = {True: "business user", False: "developer", None: "unknown"}[details.business_user]
|
|
50
|
+
return f" ({kind}: {details.business_user_reason})"
|
|
51
|
+
|
|
52
|
+
|
|
42
53
|
def format_details(details: IssueDetails, *, as_json: bool = False) -> str:
|
|
43
54
|
"""The issue as one JSON document (`as_json`, values unchanged) or as readable text (control characters shown escaped)."""
|
|
44
55
|
if as_json:
|
|
45
56
|
return json.dumps(asdict(details), ensure_ascii=False, indent=2)
|
|
46
57
|
lines = [f"#{details.number} [{details.state}] {details.title}", details.url]
|
|
47
58
|
lines += [f"{name}: {value}" for name, value in details.fields.items() if value]
|
|
59
|
+
if details.created_by:
|
|
60
|
+
lines.append(f"Created by: {details.created_by}{_creator_note(details)}")
|
|
48
61
|
if details.tags:
|
|
49
62
|
lines.append(f"Tags: {', '.join(details.tags)}")
|
|
50
63
|
if details.parent is not None:
|
|
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "bmsdna-devtools"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.47.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
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Use the `bdt` CLI (from the bmsdna-devtools package) instead of ad hoc git/az/gh
|
|
5
5
|
commands or repo-local scripts for: checking PR build/check status, creating
|
|
6
6
|
a PR, creating a git worktree, committing and pushing files (with pre-flight
|
|
7
|
-
checks), claiming an issue / work item (`bdt issue take`), showing an issue / work item and linking it to a parent / blockers (`bdt issue show`, `--parent`, `--blocked-by`, `--iteration`), finding issues addressed to you (`bdt issue search --for-me`), waiting for a reply on an issue (`bdt issue watch`), a one-shot PR summary
|
|
7
|
+
checks), claiming an issue / work item (`bdt issue take`), showing an issue / work item and linking it to a parent / blockers (`bdt issue show`, `--parent`, `--blocked-by`, `--iteration`), finding issues addressed to you (`bdt issue search --for-me`), finding work items filed by business users (`bdt issue search --business-user`), waiting for a reply on an issue (`bdt issue watch`), a one-shot PR summary
|
|
8
8
|
(`bdt pr info`: link, build state, closed issues), querying Azure logs, and static checks (`bdt lint`: SQL/psycopg/arrowbricks rules, pydantic placement, tooling; `bdt dead-code`:
|
|
9
9
|
unreferenced .sql files and backend routes nothing calls), listing which env vars a repo defines (`bdt env get-keys`, names only). `bdt pr *` works against both Azure DevOps
|
|
10
10
|
and GitHub — it auto-detects which one from the `origin` remote. Trigger
|
|
@@ -97,7 +97,7 @@ session-link comment instead.
|
|
|
97
97
|
## Reading and linking an issue
|
|
98
98
|
|
|
99
99
|
```bash
|
|
100
|
-
bdt issue show 1234 [--json] # state, description, tags, area/iteration, links (parent/children/blocked by/blocks), comments
|
|
100
|
+
bdt issue show 1234 [--json] # state, description, tags, area/iteration, links (parent/children/blocked by/blocks), comments, creator + business-user verdict (Azure DevOps)
|
|
101
101
|
bdt issue create --title "..." --parent 1200 --blocked-by 1210 [--iteration 'Project\Sprint 1']
|
|
102
102
|
bdt issue update 1234 --parent 1200 --blocked-by 1210 [--iteration 'Project\Sprint 1']
|
|
103
103
|
```
|
|
@@ -107,6 +107,12 @@ Azure DevOps; `update` only adds missing links, so it's safe to repeat. `--itera
|
|
|
107
107
|
exit 2 on GitHub) sets the Iteration Path, which Basic-process work items need to show on the team backlog;
|
|
108
108
|
on `create` it defaults to `[tool.bdt.ado].iteration` in `pyproject.toml`.
|
|
109
109
|
|
|
110
|
+
Azure DevOps only: `bdt issue show` names the creator and whether they are a **business user** (Stakeholder license,
|
|
111
|
+
or no commit in the last 1000 commits of this repo; the license lookup needs the *Member Entitlement Management
|
|
112
|
+
(read)* PAT scope, else only commits decide), and `bdt issue search --business-user [--commit-depth N]` lists only
|
|
113
|
+
work items created by business users (not with `--org-wide`). Combine with `--tag` to find e.g. their items tagged
|
|
114
|
+
`needs-techie`.
|
|
115
|
+
|
|
110
116
|
## Finding issues meant for you, and asking via an issue
|
|
111
117
|
|
|
112
118
|
```bash
|