bmsdna-devtools 0.35.0__tar.gz → 0.37.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.35.0 → bmsdna_devtools-0.37.0}/PKG-INFO +81 -6
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/README.md +80 -5
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/ado_issue.py +57 -1
- bmsdna_devtools-0.37.0/bmsdna/devtools/agent_mentions.py +82 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/cli.py +124 -8
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/gh_issue.py +56 -2
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/gh_pr.py +17 -2
- bmsdna_devtools-0.37.0/bmsdna/devtools/issue_watch.py +125 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_sql.py +147 -24
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_build.py +66 -1
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/skills/bmsdna-devtools/SKILL.md +27 -6
- bmsdna_devtools-0.37.0/tests/test_agent_mentions.py +102 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_find_injection.py +14 -0
- bmsdna_devtools-0.37.0/tests/test_issue_for_me.py +422 -0
- bmsdna_devtools-0.37.0/tests/test_issue_watch.py +183 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_lint_sql.py +221 -0
- bmsdna_devtools-0.37.0/tests/test_pr_description_not_truncated.py +138 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/uv.lock +1 -1
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/.gitignore +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/.python-version +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/_api_dump.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/api_usage.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/dead_code.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/env_files.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/find_injection.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/issue_do.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_sql_files.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_info.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/translate.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/justfile +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_api_usage.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_env_files.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_issue_do.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_lint_sql_files.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pr_info.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_translate.py +0 -0
- {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.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.37.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
|
|
@@ -15,10 +15,10 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
# bmsdna-devtools
|
|
16
16
|
|
|
17
17
|
Shared developer tooling for BMS projects: PR build/check status and a
|
|
18
|
-
one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, git worktrees (creation and
|
|
18
|
+
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
|
|
19
19
|
merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
|
|
20
20
|
pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
|
|
21
|
-
postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
21
|
+
postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
22
22
|
TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
|
|
23
23
|
backend routes nothing calls.
|
|
24
24
|
`bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
|
|
@@ -179,7 +179,15 @@ a thin wrapper around `az repos pr create` (org/project/repo inferred by
|
|
|
179
179
|
`az` itself from the git remote). On GitHub, `gh pr create --fill` (autofills
|
|
180
180
|
title/body from commit info so it never blocks on an interactive prompt).
|
|
181
181
|
Extra arguments pass through either way, e.g.
|
|
182
|
-
`bdt pr create --target dev -- --
|
|
182
|
+
`bdt pr create --target dev -- --reviewers "..."`.
|
|
183
|
+
|
|
184
|
+
Pass the text with `--title "..."` and `--description "$(cat pr.md)"` rather than as passthrough
|
|
185
|
+
args: it is kept in full. On GitHub the description goes to `gh` on stdin (`--body-file -`); on
|
|
186
|
+
Azure DevOps it is set over the REST API right after `az` creates the PR, because
|
|
187
|
+
`az repos pr create --description` has been seen cutting long text at ~450 characters (a `--title`/
|
|
188
|
+
`--description` given after `--` is intercepted the same way). Azure DevOps itself allows at most
|
|
189
|
+
4000 characters in a PR description: a longer one is rejected up front, and a description the
|
|
190
|
+
server stored shorter than sent is reported as a warning (`bdt pr update` does the same check).
|
|
183
191
|
|
|
184
192
|
PRs are created as **drafts by default**; a successful create always prints
|
|
185
193
|
the PR's link plus `bdt pr publish` (which abstracts over the host) to mark
|
|
@@ -343,6 +351,58 @@ branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it re
|
|
|
343
351
|
that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
|
|
344
352
|
says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
|
|
345
353
|
|
|
354
|
+
## `bdt issue search --for-me` / `bdt issue watch`
|
|
355
|
+
|
|
356
|
+
Issues are how people and coding agents talk to each other: someone comments `@Claude, can you look
|
|
357
|
+
at this?` on an issue, and the agent finds it; the agent asks a question in an issue and waits for the answer.
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
bdt issue search --for-me [--agent claude] [--state all] [--limit 10]
|
|
361
|
+
bdt issue watch 84 [--timeout 900] [--interval 20] [--since-comment ID] [--for-me] [--skip-own] [--json]
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
**`issue search --for-me`** only keeps issues (GitHub) / work items (Azure DevOps) whose **last comment**
|
|
365
|
+
addresses the calling agent. It combines with every other `search` filter (keywords, `--label`, `--board`,
|
|
366
|
+
`--org-wide`, ...). A comment addresses the agent when it contains `@Claude`, `@Codex`, `@Goose`, ... for
|
|
367
|
+
the agent that is running `bdt`, or `@Agent`, which addresses every agent. Matching is case-insensitive and
|
|
368
|
+
on whole words: `@claude`, `@CLAUDE,` and `@Claude.` match, `@Claudette`, `@claude-bot` and `me@claude.ai` do not.
|
|
369
|
+
Only the newest comment counts, so once someone (including the agent, e.g. with a reply) comments after the
|
|
370
|
+
mention, the issue drops out of the list. The newest comment isn't part of the list output of `gh`/Azure DevOps,
|
|
371
|
+
so this costs one extra request per candidate; candidates are scanned in recently-updated order (up to 10x
|
|
372
|
+
`--limit` on GitHub, 200 on Azure DevOps) until `--limit` have matched.
|
|
373
|
+
|
|
374
|
+
Which agent is calling is detected from the environment, first match wins: `--agent NAME` (or the `BDT_AGENT`
|
|
375
|
+
environment variable, to pin it), `CLAUDECODE=1` (Claude), the agent-agnostic `AI_AGENT` convention (first
|
|
376
|
+
token of its value, `claude-code_2-1_agent` is Claude; `gh` reads it too), then marker variables of
|
|
377
|
+
`CLAUDE_CODE_*` (Claude), `CODEX_SANDBOX*` / `CODEX_THREAD_*` / `CODEX_CI` (Codex), `GOOSE_TERMINAL` /
|
|
378
|
+
`GOOSE_SESSION` (Goose), `OPENCODE` (OpenCode) and `GEMINI_CLI=1` (Gemini). These variables are not a
|
|
379
|
+
documented contract of those tools, so use `--agent`/`BDT_AGENT` for any other agent (any name works:
|
|
380
|
+
`--agent myagent` looks for `@myagent`). With no agent detected (a human at a terminal), `--for-me` notes
|
|
381
|
+
that on stderr and matches `@Agent` only.
|
|
382
|
+
|
|
383
|
+
**`issue watch NUMBER`** (also available as `bdt watch-issue NUMBER`) blocks until a new comment arrives on the
|
|
384
|
+
issue / work item, polling every `--interval` seconds (default 20), then prints the new comment(s) and exits 0. It
|
|
385
|
+
exits **4** if nothing arrived within `--timeout` seconds (default 900; `0` waits forever), so an agent can
|
|
386
|
+
tell "no answer yet" from a failure (exit 1) and run it again. Comments that were already there when the
|
|
387
|
+
watch started are ignored -- use `--since-comment ID` (the id `issue comment add` printed) to also receive anything
|
|
388
|
+
posted after that comment, which avoids missing a reply that came in before the watch started. So an agent asks
|
|
389
|
+
and waits like this:
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
bdt issue comment add 84 --message "@aersam Should this also cover Azure DevOps?" # prints (comment #123)
|
|
393
|
+
bdt issue watch 84 --since-comment 123 --skip-own --json --timeout 1800
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
- `--for-me` only reports new comments that address the calling agent (same matching and `--agent` as `search`).
|
|
397
|
+
- `--skip-own` ignores comments this agent wrote itself: those carrying its session note (bdt appends
|
|
398
|
+
`<Agent> Session: <id>` to every comment it posts under an agent) or, outside an agent, those authored by the
|
|
399
|
+
authenticated user. Comments of a human who shares the agent's account are still reported under an agent.
|
|
400
|
+
- `--json` prints each new comment as one JSON object per line (`id`, `author`, `created_at`, `url`, `body`; Azure
|
|
401
|
+
DevOps's HTML is converted to plain text); without it a short readable block per comment. Status messages go to
|
|
402
|
+
stderr, so stdout holds only the comments.
|
|
403
|
+
- A single failed poll (network blip, API error) is retried; three in a row abort with the error.
|
|
404
|
+
- On Azure DevOps only the newest 100 comments of a work item are looked at per poll.
|
|
405
|
+
|
|
346
406
|
## `bdt env get-keys`
|
|
347
407
|
|
|
348
408
|
Lists the variable **names** (never the values) defined in `.env`, `*.env` and `.env.*` files under the
|
|
@@ -637,6 +697,21 @@ unresolvable argument, e.g. a plain function parameter, is silently skipped --
|
|
|
637
697
|
this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
|
|
638
698
|
it can't see through).
|
|
639
699
|
|
|
700
|
+
**arrowbricks** (the Databricks SQL client): `Cursor.execute`/`execute_streamed`, `DatabricksClient.query_agent`/`stream_query_json`
|
|
701
|
+
and the free `arrowbricks.stream_query_json(client, sql)` are checked as well. `execute_streamed`/`query_agent`/`stream_query_json`
|
|
702
|
+
are matched by name (the free function's SQL is its *second* argument; the method's is the first; `sql=` as keyword works too).
|
|
703
|
+
Only the injection rules apply there -- an f-string/concatenation/`%`/`.format()` is flagged and the fix suggests an
|
|
704
|
+
arrowbricks t-string (`{value}` binds a named parameter, `{name:i}` quotes an identifier) or literal SQL with
|
|
705
|
+
`parameters=[...]` (`params=[...]` on `query_agent`/`stream_query_json`) for `:name` markers. The postgres house style (`sql-inline-too-complex`, `sql-positional-param`, `sql-forbidden-join`) is not applied to
|
|
706
|
+
them, since Databricks SQL isn't postgres. The SQL is also parsed as Databricks SQL (backticks, `QUALIFY`, ...), so such
|
|
707
|
+
statements are no longer missed; a plain `.execute()` (incl. `execute(sql=...)`) gets that extra dialect only in a file that
|
|
708
|
+
imports `arrowbricks`, and keeps the postgres rules there (it may be a psycopg cursor too). The free function is recognised
|
|
709
|
+
under `from arrowbricks import stream_query_json as x` and `arrowbricks[._streaming].stream_query_json(...)`.
|
|
710
|
+
Like pgdevkit's helpers the three names match by name wherever they are called (a client injected from another module is the
|
|
711
|
+
common case), so an unrelated `agent.query_agent(f"select ...")` whose text parses as SQL is flagged too.
|
|
712
|
+
arrowbricks' `unsafe_sql(x)` -- its typing-only escape hatch -- is treated like `cast(LiteralString, x)` (in any file):
|
|
713
|
+
only as safe as `x`.
|
|
714
|
+
|
|
640
715
|
pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
|
|
641
716
|
exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
|
|
642
717
|
`pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
|
|
@@ -846,8 +921,8 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
|
|
|
846
921
|
(depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
|
|
847
922
|
code 0 unless `--strict`).
|
|
848
923
|
|
|
849
|
-
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
850
|
-
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
|
|
924
|
+
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()` / arrowbricks `execute_streamed()` / `query_agent()` / `stream_query_json()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
925
|
+
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)`/`unsafe_sql(...)` (the cast is only as safe as its argument) or
|
|
851
926
|
a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
|
|
852
927
|
call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
|
|
853
928
|
reports unverified calls.)
|
|
@@ -1,10 +1,10 @@
|
|
|
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, 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`) 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
|
-
postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
7
|
+
postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
8
8
|
TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
|
|
9
9
|
backend routes nothing calls.
|
|
10
10
|
`bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
|
|
@@ -165,7 +165,15 @@ a thin wrapper around `az repos pr create` (org/project/repo inferred by
|
|
|
165
165
|
`az` itself from the git remote). On GitHub, `gh pr create --fill` (autofills
|
|
166
166
|
title/body from commit info so it never blocks on an interactive prompt).
|
|
167
167
|
Extra arguments pass through either way, e.g.
|
|
168
|
-
`bdt pr create --target dev -- --
|
|
168
|
+
`bdt pr create --target dev -- --reviewers "..."`.
|
|
169
|
+
|
|
170
|
+
Pass the text with `--title "..."` and `--description "$(cat pr.md)"` rather than as passthrough
|
|
171
|
+
args: it is kept in full. On GitHub the description goes to `gh` on stdin (`--body-file -`); on
|
|
172
|
+
Azure DevOps it is set over the REST API right after `az` creates the PR, because
|
|
173
|
+
`az repos pr create --description` has been seen cutting long text at ~450 characters (a `--title`/
|
|
174
|
+
`--description` given after `--` is intercepted the same way). Azure DevOps itself allows at most
|
|
175
|
+
4000 characters in a PR description: a longer one is rejected up front, and a description the
|
|
176
|
+
server stored shorter than sent is reported as a warning (`bdt pr update` does the same check).
|
|
169
177
|
|
|
170
178
|
PRs are created as **drafts by default**; a successful create always prints
|
|
171
179
|
the PR's link plus `bdt pr publish` (which abstracts over the host) to mark
|
|
@@ -329,6 +337,58 @@ branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it re
|
|
|
329
337
|
that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
|
|
330
338
|
says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
|
|
331
339
|
|
|
340
|
+
## `bdt issue search --for-me` / `bdt issue watch`
|
|
341
|
+
|
|
342
|
+
Issues are how people and coding agents talk to each other: someone comments `@Claude, can you look
|
|
343
|
+
at this?` on an issue, and the agent finds it; the agent asks a question in an issue and waits for the answer.
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
bdt issue search --for-me [--agent claude] [--state all] [--limit 10]
|
|
347
|
+
bdt issue watch 84 [--timeout 900] [--interval 20] [--since-comment ID] [--for-me] [--skip-own] [--json]
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
**`issue search --for-me`** only keeps issues (GitHub) / work items (Azure DevOps) whose **last comment**
|
|
351
|
+
addresses the calling agent. It combines with every other `search` filter (keywords, `--label`, `--board`,
|
|
352
|
+
`--org-wide`, ...). A comment addresses the agent when it contains `@Claude`, `@Codex`, `@Goose`, ... for
|
|
353
|
+
the agent that is running `bdt`, or `@Agent`, which addresses every agent. Matching is case-insensitive and
|
|
354
|
+
on whole words: `@claude`, `@CLAUDE,` and `@Claude.` match, `@Claudette`, `@claude-bot` and `me@claude.ai` do not.
|
|
355
|
+
Only the newest comment counts, so once someone (including the agent, e.g. with a reply) comments after the
|
|
356
|
+
mention, the issue drops out of the list. The newest comment isn't part of the list output of `gh`/Azure DevOps,
|
|
357
|
+
so this costs one extra request per candidate; candidates are scanned in recently-updated order (up to 10x
|
|
358
|
+
`--limit` on GitHub, 200 on Azure DevOps) until `--limit` have matched.
|
|
359
|
+
|
|
360
|
+
Which agent is calling is detected from the environment, first match wins: `--agent NAME` (or the `BDT_AGENT`
|
|
361
|
+
environment variable, to pin it), `CLAUDECODE=1` (Claude), the agent-agnostic `AI_AGENT` convention (first
|
|
362
|
+
token of its value, `claude-code_2-1_agent` is Claude; `gh` reads it too), then marker variables of
|
|
363
|
+
`CLAUDE_CODE_*` (Claude), `CODEX_SANDBOX*` / `CODEX_THREAD_*` / `CODEX_CI` (Codex), `GOOSE_TERMINAL` /
|
|
364
|
+
`GOOSE_SESSION` (Goose), `OPENCODE` (OpenCode) and `GEMINI_CLI=1` (Gemini). These variables are not a
|
|
365
|
+
documented contract of those tools, so use `--agent`/`BDT_AGENT` for any other agent (any name works:
|
|
366
|
+
`--agent myagent` looks for `@myagent`). With no agent detected (a human at a terminal), `--for-me` notes
|
|
367
|
+
that on stderr and matches `@Agent` only.
|
|
368
|
+
|
|
369
|
+
**`issue watch NUMBER`** (also available as `bdt watch-issue NUMBER`) blocks until a new comment arrives on the
|
|
370
|
+
issue / work item, polling every `--interval` seconds (default 20), then prints the new comment(s) and exits 0. It
|
|
371
|
+
exits **4** if nothing arrived within `--timeout` seconds (default 900; `0` waits forever), so an agent can
|
|
372
|
+
tell "no answer yet" from a failure (exit 1) and run it again. Comments that were already there when the
|
|
373
|
+
watch started are ignored -- use `--since-comment ID` (the id `issue comment add` printed) to also receive anything
|
|
374
|
+
posted after that comment, which avoids missing a reply that came in before the watch started. So an agent asks
|
|
375
|
+
and waits like this:
|
|
376
|
+
|
|
377
|
+
```bash
|
|
378
|
+
bdt issue comment add 84 --message "@aersam Should this also cover Azure DevOps?" # prints (comment #123)
|
|
379
|
+
bdt issue watch 84 --since-comment 123 --skip-own --json --timeout 1800
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
- `--for-me` only reports new comments that address the calling agent (same matching and `--agent` as `search`).
|
|
383
|
+
- `--skip-own` ignores comments this agent wrote itself: those carrying its session note (bdt appends
|
|
384
|
+
`<Agent> Session: <id>` to every comment it posts under an agent) or, outside an agent, those authored by the
|
|
385
|
+
authenticated user. Comments of a human who shares the agent's account are still reported under an agent.
|
|
386
|
+
- `--json` prints each new comment as one JSON object per line (`id`, `author`, `created_at`, `url`, `body`; Azure
|
|
387
|
+
DevOps's HTML is converted to plain text); without it a short readable block per comment. Status messages go to
|
|
388
|
+
stderr, so stdout holds only the comments.
|
|
389
|
+
- A single failed poll (network blip, API error) is retried; three in a row abort with the error.
|
|
390
|
+
- On Azure DevOps only the newest 100 comments of a work item are looked at per poll.
|
|
391
|
+
|
|
332
392
|
## `bdt env get-keys`
|
|
333
393
|
|
|
334
394
|
Lists the variable **names** (never the values) defined in `.env`, `*.env` and `.env.*` files under the
|
|
@@ -623,6 +683,21 @@ unresolvable argument, e.g. a plain function parameter, is silently skipped --
|
|
|
623
683
|
this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
|
|
624
684
|
it can't see through).
|
|
625
685
|
|
|
686
|
+
**arrowbricks** (the Databricks SQL client): `Cursor.execute`/`execute_streamed`, `DatabricksClient.query_agent`/`stream_query_json`
|
|
687
|
+
and the free `arrowbricks.stream_query_json(client, sql)` are checked as well. `execute_streamed`/`query_agent`/`stream_query_json`
|
|
688
|
+
are matched by name (the free function's SQL is its *second* argument; the method's is the first; `sql=` as keyword works too).
|
|
689
|
+
Only the injection rules apply there -- an f-string/concatenation/`%`/`.format()` is flagged and the fix suggests an
|
|
690
|
+
arrowbricks t-string (`{value}` binds a named parameter, `{name:i}` quotes an identifier) or literal SQL with
|
|
691
|
+
`parameters=[...]` (`params=[...]` on `query_agent`/`stream_query_json`) for `:name` markers. The postgres house style (`sql-inline-too-complex`, `sql-positional-param`, `sql-forbidden-join`) is not applied to
|
|
692
|
+
them, since Databricks SQL isn't postgres. The SQL is also parsed as Databricks SQL (backticks, `QUALIFY`, ...), so such
|
|
693
|
+
statements are no longer missed; a plain `.execute()` (incl. `execute(sql=...)`) gets that extra dialect only in a file that
|
|
694
|
+
imports `arrowbricks`, and keeps the postgres rules there (it may be a psycopg cursor too). The free function is recognised
|
|
695
|
+
under `from arrowbricks import stream_query_json as x` and `arrowbricks[._streaming].stream_query_json(...)`.
|
|
696
|
+
Like pgdevkit's helpers the three names match by name wherever they are called (a client injected from another module is the
|
|
697
|
+
common case), so an unrelated `agent.query_agent(f"select ...")` whose text parses as SQL is flagged too.
|
|
698
|
+
arrowbricks' `unsafe_sql(x)` -- its typing-only escape hatch -- is treated like `cast(LiteralString, x)` (in any file):
|
|
699
|
+
only as safe as `x`.
|
|
700
|
+
|
|
626
701
|
pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
|
|
627
702
|
exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
|
|
628
703
|
`pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
|
|
@@ -832,8 +907,8 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
|
|
|
832
907
|
(depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
|
|
833
908
|
code 0 unless `--strict`).
|
|
834
909
|
|
|
835
|
-
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
836
|
-
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
|
|
910
|
+
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()` / arrowbricks `execute_streamed()` / `query_agent()` / `stream_query_json()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
911
|
+
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)`/`unsafe_sql(...)` (the cast is only as safe as its argument) or
|
|
837
912
|
a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
|
|
838
913
|
call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
|
|
839
914
|
reports unverified calls.)
|
|
@@ -41,6 +41,8 @@ a field is saved as Markdown it can't be switched back to HTML via the API.
|
|
|
41
41
|
from __future__ import annotations
|
|
42
42
|
|
|
43
43
|
import sys
|
|
44
|
+
from collections.abc import Callable
|
|
45
|
+
from dataclasses import replace
|
|
44
46
|
from pathlib import Path
|
|
45
47
|
from urllib.parse import quote
|
|
46
48
|
|
|
@@ -325,6 +327,32 @@ def last_comment(session: requests.Session, remote: AdoRemote, work_item_id: int
|
|
|
325
327
|
return comments[0].get("text") if comments else None
|
|
326
328
|
|
|
327
329
|
|
|
330
|
+
# Newest comments to fetch per poll of a work item (see `list_comments`) -- a watcher only ever needs
|
|
331
|
+
# what arrived since its last poll, so this is far more than enough while keeping one request per poll.
|
|
332
|
+
_LIST_COMMENTS_TOP = 100
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def list_comments(session: requests.Session, remote: AdoRemote, work_item_id: int) -> list[dict]:
|
|
336
|
+
"""The work item's newest comments (at most `_LIST_COMMENTS_TOP`), oldest first, as
|
|
337
|
+
`{id, author, created_at, url, body}` -- `body` is the comment's HTML, as stored."""
|
|
338
|
+
r = session.get(
|
|
339
|
+
f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments",
|
|
340
|
+
params={"api-version": COMMENTS_API_VERSION, "$top": _LIST_COMMENTS_TOP, "order": "desc"},
|
|
341
|
+
)
|
|
342
|
+
r.raise_for_status()
|
|
343
|
+
url = edit_url(remote, work_item_id)
|
|
344
|
+
return [
|
|
345
|
+
{
|
|
346
|
+
"id": c["id"],
|
|
347
|
+
"author": (c.get("createdBy") or {}).get("displayName") or "unknown",
|
|
348
|
+
"created_at": c.get("createdDate") or "",
|
|
349
|
+
"url": url,
|
|
350
|
+
"body": c.get("text") or "",
|
|
351
|
+
}
|
|
352
|
+
for c in reversed(r.json().get("comments") or [])
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
|
|
328
356
|
def update_comment(session: requests.Session, remote: AdoRemote, work_item_id: int, comment_id: int, text: str) -> dict:
|
|
329
357
|
r = session.patch(
|
|
330
358
|
f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments/{comment_id}",
|
|
@@ -524,6 +552,27 @@ def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int],
|
|
|
524
552
|
return [by_id[i] for i in ids if i in by_id]
|
|
525
553
|
|
|
526
554
|
|
|
555
|
+
# Max candidates a comment-filtered `search()` looks at -- also the most work item ids the batch
|
|
556
|
+
# `get_work_items` endpoint accepts per request.
|
|
557
|
+
_COMMENT_FILTER_POOL = 200
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _filter_by_last_comment(
|
|
561
|
+
session: requests.Session, remote: AdoRemote, items: list[dict], top: int, comment_filter: Callable[[str | None], bool]
|
|
562
|
+
) -> list[dict]:
|
|
563
|
+
"""The first `top` of `items` (in order) whose newest comment `comment_filter` accepts. An
|
|
564
|
+
item from another project (org-wide search) is looked up in its own project."""
|
|
565
|
+
matched: list[dict] = []
|
|
566
|
+
for item in items:
|
|
567
|
+
if len(matched) >= top:
|
|
568
|
+
break
|
|
569
|
+
project = item["fields"].get("System.TeamProject")
|
|
570
|
+
item_remote = replace(remote, project=project) if project else remote
|
|
571
|
+
if comment_filter(last_comment(session, item_remote, item["id"])):
|
|
572
|
+
matched.append(item)
|
|
573
|
+
return matched
|
|
574
|
+
|
|
575
|
+
|
|
527
576
|
def search(
|
|
528
577
|
session: requests.Session,
|
|
529
578
|
remote: AdoRemote,
|
|
@@ -534,6 +583,7 @@ def search(
|
|
|
534
583
|
state: str = "open",
|
|
535
584
|
org_wide: bool = False,
|
|
536
585
|
tags: list[str] | None = None,
|
|
586
|
+
comment_filter: Callable[[str | None], bool] | None = None,
|
|
537
587
|
) -> list[dict]:
|
|
538
588
|
"""Search (or, with no keywords, just list) work items by keywords (ANDed, matched against
|
|
539
589
|
Title or Description) and state, most recently changed first. `board` is an Azure Boards team
|
|
@@ -544,13 +594,19 @@ def search(
|
|
|
544
594
|
`org_wide` searches every project in `remote.org` instead of just `remote.project` --
|
|
545
595
|
mutually exclusive with `board`, since a team's Area Path only means something within a
|
|
546
596
|
single project.
|
|
597
|
+
|
|
598
|
+
`comment_filter` keeps only work items whose newest comment (None if it has none) it accepts --
|
|
599
|
+
e.g. `bdt issue search --for-me`. WIQL can't query comments, so up to `_COMMENT_FILTER_POOL`
|
|
600
|
+
candidates are fetched and checked one by one, stopping once `top` have matched.
|
|
547
601
|
"""
|
|
548
602
|
if org_wide and board:
|
|
549
603
|
sys.exit("--board can't be combined with --org-wide (a board's Area Path is scoped to a single project).")
|
|
550
604
|
area_path = get_team_area_path(session, remote, board) if board else None
|
|
551
605
|
wiql = build_search_wiql(keywords, since, area_path, state, org_wide=org_wide, tags=tags)
|
|
552
|
-
ids = run_wiql(session, remote, wiql, top, org_wide=org_wide)
|
|
606
|
+
ids = run_wiql(session, remote, wiql, min(top * 10, _COMMENT_FILTER_POOL) if comment_filter else top, org_wide=org_wide)
|
|
553
607
|
items = get_work_items(session, remote, ids, org_wide=org_wide)
|
|
608
|
+
if comment_filter:
|
|
609
|
+
items = _filter_by_last_comment(session, remote, items, top, comment_filter)
|
|
554
610
|
for item in items:
|
|
555
611
|
fields = item["fields"]
|
|
556
612
|
project = fields.get("System.TeamProject")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Which coding agent is calling `bdt`, and does a comment address it?
|
|
2
|
+
|
|
3
|
+
Backs `bdt issue search --for-me` and `bdt issue watch`: people (or other agents) address an agent
|
|
4
|
+
in an issue comment with `@Claude`, `@Codex`, `@Goose`, ... and `@Agent` addresses all of them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
|
|
13
|
+
# A mention addressed to every agent, whatever the caller is.
|
|
14
|
+
ANY_AGENT = "agent"
|
|
15
|
+
|
|
16
|
+
# Env var that overrides detection (the `--agent` option of `issue search`/`issue watch` reads it too).
|
|
17
|
+
AGENT_ENV_VAR = "BDT_AGENT"
|
|
18
|
+
|
|
19
|
+
# Mention handles of the agents we know how to detect, with the environment each one leaves in the
|
|
20
|
+
# processes it spawns. Best-effort, like `cli_tools.detect_agent_session`: none of these is a
|
|
21
|
+
# documented, stable contract, which is why an explicit override always wins.
|
|
22
|
+
_ENV_EXACT: tuple[tuple[str, str, str], ...] = (("CLAUDECODE", "1", "claude"), ("GEMINI_CLI", "1", "gemini"))
|
|
23
|
+
_ENV_PREFIXES: tuple[tuple[str, str], ...] = (
|
|
24
|
+
("CLAUDE_CODE_", "claude"),
|
|
25
|
+
("CODEX_SANDBOX", "codex"),
|
|
26
|
+
("CODEX_THREAD_", "codex"),
|
|
27
|
+
("CODEX_CI", "codex"),
|
|
28
|
+
("GOOSE_TERMINAL", "goose"),
|
|
29
|
+
("GOOSE_SESSION", "goose"),
|
|
30
|
+
("OPENCODE", "opencode"),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_agent(name: str) -> str:
|
|
35
|
+
"""The mention handle for an agent name or tag: 'Claude' / 'claude-code_2-1_agent' -> 'claude',
|
|
36
|
+
'Codex CLI' -> 'codex', '@Goose' -> 'goose'."""
|
|
37
|
+
tag = re.split(r"[\s_-]+", name.strip().lstrip("@").lower(), maxsplit=1)[0]
|
|
38
|
+
return tag
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def detect_agent(env: Mapping[str, str] | None = None, override: str | None = None) -> str | None:
|
|
42
|
+
"""The calling agent's mention handle (lowercase, e.g. 'claude'), or None when not run by one.
|
|
43
|
+
|
|
44
|
+
Order: an explicit `override` (the `--agent` option), then `BDT_AGENT`, then `CLAUDECODE=1`,
|
|
45
|
+
then the agent-agnostic `AI_AGENT` convention (the first token of its value; `gh` reads the same
|
|
46
|
+
variable), then each agent's own marker variables (`CLAUDE_CODE_*`, `CODEX_*`, `GOOSE_*`, ...).
|
|
47
|
+
"""
|
|
48
|
+
env = os.environ if env is None else env
|
|
49
|
+
for explicit in (override, env.get(AGENT_ENV_VAR)):
|
|
50
|
+
if explicit and explicit.strip():
|
|
51
|
+
return normalize_agent(explicit)
|
|
52
|
+
for var, value, handle in _ENV_EXACT:
|
|
53
|
+
if env.get(var) == value:
|
|
54
|
+
return handle
|
|
55
|
+
ai_agent = env.get("AI_AGENT", "").strip()
|
|
56
|
+
if ai_agent:
|
|
57
|
+
return normalize_agent(ai_agent)
|
|
58
|
+
for prefix, handle in _ENV_PREFIXES:
|
|
59
|
+
if any(key.startswith(prefix) and env[key] for key in env):
|
|
60
|
+
return handle
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def mentions_agent(text: str | None, agent: str | None) -> bool:
|
|
65
|
+
"""Does `text` contain an `@<agent>` or `@Agent` mention?
|
|
66
|
+
|
|
67
|
+
Case-insensitive, and the handle must be a whole word: `@Claude` and `@claude,` match but
|
|
68
|
+
`@Claudette`, `@claude-bot` and `name@claude.com` do not. With `agent=None` (no agent detected)
|
|
69
|
+
only `@Agent` matches.
|
|
70
|
+
"""
|
|
71
|
+
if not text:
|
|
72
|
+
return False
|
|
73
|
+
handles = {ANY_AGENT}
|
|
74
|
+
if agent:
|
|
75
|
+
handles.add(normalize_agent(agent))
|
|
76
|
+
pattern = r"(?<![\w@])@(?:" + "|".join(re.escape(h) for h in sorted(handles)) + r")(?![\w@]|-\w)"
|
|
77
|
+
return re.search(pattern, text, re.IGNORECASE) is not None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def last_comment_filter(agent: str | None) -> Callable[[str | None], bool]:
|
|
81
|
+
"""A predicate over an issue's newest comment (None = no comments): does it address `agent`?"""
|
|
82
|
+
return lambda text: mentions_agent(text, agent)
|