bmsdna-devtools 0.28.1__tar.gz → 0.30.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.28.1 → bmsdna_devtools-0.30.0}/PKG-INFO +54 -8
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/README.md +53 -7
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/ado_issue.py +19 -2
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/cli.py +124 -11
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/gh_issue.py +22 -2
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/gh_pr.py +6 -2
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/issue_do.py +23 -5
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/lint_sql.py +48 -9
- bmsdna_devtools-0.30.0/bmsdna/devtools/pr_info.py +138 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/skills/bmsdna-devtools/SKILL.md +30 -5
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_issue_do.py +11 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_lint_sql.py +73 -0
- bmsdna_devtools-0.30.0/tests/test_pr_info.py +422 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/uv.lock +1 -1
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/.gitignore +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/.python-version +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/find_injection.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/justfile +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_find_injection.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.28.1 → bmsdna_devtools-0.30.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.30.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
|
|
@@ -13,8 +13,8 @@ Description-Content-Type: text/markdown
|
|
|
13
13
|
|
|
14
14
|
# bmsdna-devtools
|
|
15
15
|
|
|
16
|
-
Shared developer tooling for BMS projects: PR build/check status
|
|
17
|
-
creation, issue/work item creation and comments, git worktrees (creation and
|
|
16
|
+
Shared developer tooling for BMS projects: PR build/check status and a
|
|
17
|
+
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
18
|
merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
|
|
19
19
|
pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
|
|
20
20
|
postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
@@ -48,9 +48,12 @@ Find the PR opened from the current branch and report build/check status
|
|
|
48
48
|
GitHub — whichever `origin` points at.
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
bdt pr status [--target-branch
|
|
51
|
+
bdt pr status [--target-branch dev] [--wait]
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
Every `--target` / `--target-branch` option in bdt (`pr create/status/info/retry/publish/update/comment/watch-deploy`,
|
|
55
|
+
`issue take`, `commit`) defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override.
|
|
56
|
+
|
|
54
57
|
If the PR can't be merged, that's reported immediately instead of polling
|
|
55
58
|
for builds/checks that will never run — e.g. on Azure DevOps:
|
|
56
59
|
`PR #42 ('feat: widgets') has merge conflicts with the target branch (mergeStatus=conflicts)`;
|
|
@@ -85,6 +88,25 @@ locally at all. `bdt pr retry`, `pr publish`, `pr update`, and `pr comment`
|
|
|
85
88
|
all accept the same option, for the same reason. (`pr watch-deploy` doesn't:
|
|
86
89
|
it watches a branch-triggered build/workflow run, not any particular PR.)
|
|
87
90
|
|
|
91
|
+
## `bdt pr info`
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
bdt pr info [--json] [--pr-id N] [--target-branch dev]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
|
|
98
|
+
build state (`passing`, `failing`, `pending`, `waiting` for a manual approval, `none`) and the
|
|
99
|
+
issue(s)/work item(s) it closes, with links. Exits 1 when the branch has no PR. `--json` is what
|
|
100
|
+
a status integration reads (e.g. the Claude Code mod planned in bmsuisse/skills#61). Build state is judged the way `bdt pr status` does: on GitHub from
|
|
101
|
+
the check rollup (only a failed check fails it; skipped/cancelled ones don't count), on Azure DevOps
|
|
102
|
+
from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
|
|
103
|
+
`waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked.
|
|
104
|
+
|
|
105
|
+
Azure DevOps limits: the PR is found by the current branch into `--target-branch` (default `dev`),
|
|
106
|
+
a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed
|
|
107
|
+
work items are only those whose URL is in the PR description -- not ones linked via
|
|
108
|
+
`bdt pr create --issue`.
|
|
109
|
+
|
|
88
110
|
## `bdt pr retry`
|
|
89
111
|
|
|
90
112
|
Retry only the **failed** job(s)/stage(s) of the most recent build/run for the
|
|
@@ -92,7 +114,7 @@ PR opened from the current branch — not a whole new build/run. Whenever `bdt
|
|
|
92
114
|
pr status` reports a failure, it prints a hint to run this.
|
|
93
115
|
|
|
94
116
|
```bash
|
|
95
|
-
bdt pr retry [--target-branch
|
|
117
|
+
bdt pr retry [--target-branch dev]
|
|
96
118
|
```
|
|
97
119
|
|
|
98
120
|
No build/run ID needed — like `bdt pr status`, it resolves the PR (and its
|
|
@@ -125,7 +147,7 @@ workflow run triggered *directly* on `--target-branch` and reports its
|
|
|
125
147
|
status the same way `pr status` does (failed steps print their logs inline).
|
|
126
148
|
|
|
127
149
|
```bash
|
|
128
|
-
bdt pr watch-deploy [--target-branch
|
|
150
|
+
bdt pr watch-deploy [--target-branch dev] [--wait]
|
|
129
151
|
```
|
|
130
152
|
|
|
131
153
|
After `bdt pr status` reports the PR's build/checks succeeded, if a build/
|
|
@@ -146,7 +168,7 @@ branch. Failed steps are printed via `gh run view <id> --log-failed`.
|
|
|
146
168
|
## `bdt pr create`
|
|
147
169
|
|
|
148
170
|
```bash
|
|
149
|
-
bdt pr create --target
|
|
171
|
+
bdt pr create --target dev # or --target main / test
|
|
150
172
|
```
|
|
151
173
|
|
|
152
174
|
Creates a PR from the current branch into `--target`. On Azure DevOps,
|
|
@@ -154,7 +176,7 @@ a thin wrapper around `az repos pr create` (org/project/repo inferred by
|
|
|
154
176
|
`az` itself from the git remote). On GitHub, `gh pr create --fill` (autofills
|
|
155
177
|
title/body from commit info so it never blocks on an interactive prompt).
|
|
156
178
|
Extra arguments pass through either way, e.g.
|
|
157
|
-
`bdt pr create --target
|
|
179
|
+
`bdt pr create --target dev -- --title "..."`.
|
|
158
180
|
|
|
159
181
|
PRs are created as **drafts by default**; a successful create always prints
|
|
160
182
|
the PR's link plus `bdt pr publish` (which abstracts over the host) to mark
|
|
@@ -298,6 +320,26 @@ description and runs `claude -p "<prompt>" --name "60: <title>"`, so the session
|
|
|
298
320
|
with `claude --resume`. `--agent` picks another executable (it then gets just the prompt as its
|
|
299
321
|
last argument); extra args are passed through; `--dry-run` prints the command.
|
|
300
322
|
|
|
323
|
+
It takes the issue first (like `bdt issue take`): before the agent starts it comments `Taken by <you>`
|
|
324
|
+
on the issue, naming the session it is about to start -- for `claude` a `--session-id` is generated and
|
|
325
|
+
passed, so the comment says `claude --resume <id>`; the prompt tells the agent not to take it again. For
|
|
326
|
+
another `--agent` the comment just says `(via <agent>)`. `--dry-run` posts nothing. Like `issue take`, it doesn't comment again when the newest comment already is a claim.
|
|
327
|
+
|
|
328
|
+
## `bdt issue take`
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
bdt issue take [NUMBER] [--target-branch dev]
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Claims an issue (GitHub) or work item (Azure DevOps): comments `Taken by <you>` on it, followed by the
|
|
335
|
+
running coding agent's session (the same `<Agent> Session: <id>` note every bdt comment gets: a
|
|
336
|
+
claude.ai link under a bridged Claude session, otherwise the bare session id; nothing outside an
|
|
337
|
+
agent), so others can see who is on it. `<you>` is the GitHub login
|
|
338
|
+
`gh` is authenticated as, else the git `user.name`. Without `NUMBER` it takes the issue the current
|
|
339
|
+
branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it refuses to guess if
|
|
340
|
+
that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
|
|
341
|
+
says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
|
|
342
|
+
|
|
301
343
|
## `bdt worktree`
|
|
302
344
|
|
|
303
345
|
```bash
|
|
@@ -548,6 +590,10 @@ it can't see through):
|
|
|
548
590
|
|
|
549
591
|
- **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
|
|
550
592
|
JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
|
|
593
|
+
Simple `INSERT`/`UPDATE`/`DELETE` are exempt from the line limit; `INSERT ... SELECT`,
|
|
594
|
+
`UPDATE ... FROM` and `DELETE ... USING` still count as complex.
|
|
595
|
+
Also applied to any `*_sql`/`*_SQL` variable assigned a literal, even if it never reaches an
|
|
596
|
+
`.execute()` call in the same file (e.g. it's handed to a helper).
|
|
551
597
|
- **`sql-fstring-injection`** / **`sql-concat-injection`** /
|
|
552
598
|
**`sql-percent-format-injection`** / **`sql-format-injection`** — SQL built
|
|
553
599
|
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bmsdna-devtools
|
|
2
2
|
|
|
3
|
-
Shared developer tooling for BMS projects: PR build/check status
|
|
4
|
-
creation, issue/work item creation and comments, git worktrees (creation and
|
|
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
|
|
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 SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
@@ -35,9 +35,12 @@ Find the PR opened from the current branch and report build/check status
|
|
|
35
35
|
GitHub — whichever `origin` points at.
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
bdt pr status [--target-branch
|
|
38
|
+
bdt pr status [--target-branch dev] [--wait]
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
Every `--target` / `--target-branch` option in bdt (`pr create/status/info/retry/publish/update/comment/watch-deploy`,
|
|
42
|
+
`issue take`, `commit`) defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override.
|
|
43
|
+
|
|
41
44
|
If the PR can't be merged, that's reported immediately instead of polling
|
|
42
45
|
for builds/checks that will never run — e.g. on Azure DevOps:
|
|
43
46
|
`PR #42 ('feat: widgets') has merge conflicts with the target branch (mergeStatus=conflicts)`;
|
|
@@ -72,6 +75,25 @@ locally at all. `bdt pr retry`, `pr publish`, `pr update`, and `pr comment`
|
|
|
72
75
|
all accept the same option, for the same reason. (`pr watch-deploy` doesn't:
|
|
73
76
|
it watches a branch-triggered build/workflow run, not any particular PR.)
|
|
74
77
|
|
|
78
|
+
## `bdt pr info`
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
bdt pr info [--json] [--pr-id N] [--target-branch dev]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
|
|
85
|
+
build state (`passing`, `failing`, `pending`, `waiting` for a manual approval, `none`) and the
|
|
86
|
+
issue(s)/work item(s) it closes, with links. Exits 1 when the branch has no PR. `--json` is what
|
|
87
|
+
a status integration reads (e.g. the Claude Code mod planned in bmsuisse/skills#61). Build state is judged the way `bdt pr status` does: on GitHub from
|
|
88
|
+
the check rollup (only a failed check fails it; skipped/cancelled ones don't count), on Azure DevOps
|
|
89
|
+
from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
|
|
90
|
+
`waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked.
|
|
91
|
+
|
|
92
|
+
Azure DevOps limits: the PR is found by the current branch into `--target-branch` (default `dev`),
|
|
93
|
+
a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed
|
|
94
|
+
work items are only those whose URL is in the PR description -- not ones linked via
|
|
95
|
+
`bdt pr create --issue`.
|
|
96
|
+
|
|
75
97
|
## `bdt pr retry`
|
|
76
98
|
|
|
77
99
|
Retry only the **failed** job(s)/stage(s) of the most recent build/run for the
|
|
@@ -79,7 +101,7 @@ PR opened from the current branch — not a whole new build/run. Whenever `bdt
|
|
|
79
101
|
pr status` reports a failure, it prints a hint to run this.
|
|
80
102
|
|
|
81
103
|
```bash
|
|
82
|
-
bdt pr retry [--target-branch
|
|
104
|
+
bdt pr retry [--target-branch dev]
|
|
83
105
|
```
|
|
84
106
|
|
|
85
107
|
No build/run ID needed — like `bdt pr status`, it resolves the PR (and its
|
|
@@ -112,7 +134,7 @@ workflow run triggered *directly* on `--target-branch` and reports its
|
|
|
112
134
|
status the same way `pr status` does (failed steps print their logs inline).
|
|
113
135
|
|
|
114
136
|
```bash
|
|
115
|
-
bdt pr watch-deploy [--target-branch
|
|
137
|
+
bdt pr watch-deploy [--target-branch dev] [--wait]
|
|
116
138
|
```
|
|
117
139
|
|
|
118
140
|
After `bdt pr status` reports the PR's build/checks succeeded, if a build/
|
|
@@ -133,7 +155,7 @@ branch. Failed steps are printed via `gh run view <id> --log-failed`.
|
|
|
133
155
|
## `bdt pr create`
|
|
134
156
|
|
|
135
157
|
```bash
|
|
136
|
-
bdt pr create --target
|
|
158
|
+
bdt pr create --target dev # or --target main / test
|
|
137
159
|
```
|
|
138
160
|
|
|
139
161
|
Creates a PR from the current branch into `--target`. On Azure DevOps,
|
|
@@ -141,7 +163,7 @@ a thin wrapper around `az repos pr create` (org/project/repo inferred by
|
|
|
141
163
|
`az` itself from the git remote). On GitHub, `gh pr create --fill` (autofills
|
|
142
164
|
title/body from commit info so it never blocks on an interactive prompt).
|
|
143
165
|
Extra arguments pass through either way, e.g.
|
|
144
|
-
`bdt pr create --target
|
|
166
|
+
`bdt pr create --target dev -- --title "..."`.
|
|
145
167
|
|
|
146
168
|
PRs are created as **drafts by default**; a successful create always prints
|
|
147
169
|
the PR's link plus `bdt pr publish` (which abstracts over the host) to mark
|
|
@@ -285,6 +307,26 @@ description and runs `claude -p "<prompt>" --name "60: <title>"`, so the session
|
|
|
285
307
|
with `claude --resume`. `--agent` picks another executable (it then gets just the prompt as its
|
|
286
308
|
last argument); extra args are passed through; `--dry-run` prints the command.
|
|
287
309
|
|
|
310
|
+
It takes the issue first (like `bdt issue take`): before the agent starts it comments `Taken by <you>`
|
|
311
|
+
on the issue, naming the session it is about to start -- for `claude` a `--session-id` is generated and
|
|
312
|
+
passed, so the comment says `claude --resume <id>`; the prompt tells the agent not to take it again. For
|
|
313
|
+
another `--agent` the comment just says `(via <agent>)`. `--dry-run` posts nothing. Like `issue take`, it doesn't comment again when the newest comment already is a claim.
|
|
314
|
+
|
|
315
|
+
## `bdt issue take`
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
bdt issue take [NUMBER] [--target-branch dev]
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Claims an issue (GitHub) or work item (Azure DevOps): comments `Taken by <you>` on it, followed by the
|
|
322
|
+
running coding agent's session (the same `<Agent> Session: <id>` note every bdt comment gets: a
|
|
323
|
+
claude.ai link under a bridged Claude session, otherwise the bare session id; nothing outside an
|
|
324
|
+
agent), so others can see who is on it. `<you>` is the GitHub login
|
|
325
|
+
`gh` is authenticated as, else the git `user.name`. Without `NUMBER` it takes the issue the current
|
|
326
|
+
branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it refuses to guess if
|
|
327
|
+
that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
|
|
328
|
+
says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
|
|
329
|
+
|
|
288
330
|
## `bdt worktree`
|
|
289
331
|
|
|
290
332
|
```bash
|
|
@@ -535,6 +577,10 @@ it can't see through):
|
|
|
535
577
|
|
|
536
578
|
- **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
|
|
537
579
|
JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
|
|
580
|
+
Simple `INSERT`/`UPDATE`/`DELETE` are exempt from the line limit; `INSERT ... SELECT`,
|
|
581
|
+
`UPDATE ... FROM` and `DELETE ... USING` still count as complex.
|
|
582
|
+
Also applied to any `*_sql`/`*_SQL` variable assigned a literal, even if it never reaches an
|
|
583
|
+
`.execute()` call in the same file (e.g. it's handed to a helper).
|
|
538
584
|
- **`sql-fstring-injection`** / **`sql-concat-injection`** /
|
|
539
585
|
**`sql-percent-format-injection`** / **`sql-format-injection`** — SQL built
|
|
540
586
|
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
@@ -314,6 +314,17 @@ def add_comment(session: requests.Session, remote: AdoRemote, work_item_id: int,
|
|
|
314
314
|
return r.json()
|
|
315
315
|
|
|
316
316
|
|
|
317
|
+
def last_comment(session: requests.Session, remote: AdoRemote, work_item_id: int) -> str | None:
|
|
318
|
+
"""Text (HTML, as stored) of the work item's newest comment, or None if it has none."""
|
|
319
|
+
r = session.get(
|
|
320
|
+
f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments",
|
|
321
|
+
params={"api-version": COMMENTS_API_VERSION, "$top": 1, "order": "desc"},
|
|
322
|
+
)
|
|
323
|
+
r.raise_for_status()
|
|
324
|
+
comments = r.json().get("comments") or []
|
|
325
|
+
return comments[0].get("text") if comments else None
|
|
326
|
+
|
|
327
|
+
|
|
317
328
|
def update_comment(session: requests.Session, remote: AdoRemote, work_item_id: int, comment_id: int, text: str) -> dict:
|
|
318
329
|
r = session.patch(
|
|
319
330
|
f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments/{comment_id}",
|
|
@@ -379,14 +390,20 @@ def comment_with_screenshots(
|
|
|
379
390
|
message: str | None,
|
|
380
391
|
screenshot_paths: list[str],
|
|
381
392
|
file_paths: list[str] | None = None,
|
|
393
|
+
agent_note: bool = True,
|
|
382
394
|
) -> dict:
|
|
383
|
-
"""Post a comment, with a message and/or screenshots/files, on the work item.
|
|
395
|
+
"""Post a comment, with a message and/or screenshots/files, on the work item.
|
|
396
|
+
|
|
397
|
+
`agent_note=False` leaves out the detected agent-session note (see `gh_issue.comment`).
|
|
398
|
+
"""
|
|
384
399
|
file_paths = file_paths or []
|
|
385
400
|
images = _upload_attachments(session, remote, screenshot_paths) if screenshot_paths else []
|
|
386
401
|
files = _upload_attachments(session, remote, file_paths) if file_paths else []
|
|
387
402
|
if images or files:
|
|
388
403
|
link_attachments(session, remote, work_item_id, images + files)
|
|
389
|
-
content =
|
|
404
|
+
content = build_comment_content_html(message, images, files) or ""
|
|
405
|
+
if agent_note:
|
|
406
|
+
content = ensure_agent_session_note(content) or ""
|
|
390
407
|
comment = add_comment(session, remote, work_item_id, content)
|
|
391
408
|
print(
|
|
392
409
|
f"Added comment #{comment['id']} ({len(screenshot_paths)} screenshot(s), {len(file_paths)} file(s)) "
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
7
|
+
import uuid
|
|
6
8
|
from collections.abc import Callable
|
|
7
9
|
from datetime import datetime, timedelta, timezone
|
|
8
10
|
from importlib.metadata import version as _pkg_version
|
|
@@ -20,9 +22,9 @@ from . import gh_issue, gh_pr
|
|
|
20
22
|
from . import find_injection as find_injection_mod
|
|
21
23
|
from . import lint as lint_mod
|
|
22
24
|
from . import logs as logs_mod
|
|
23
|
-
from . import pr_build, pr_issue_link, pr_labels, pull as pull_mod, worktree as worktree_mod
|
|
25
|
+
from . import pr_build, pr_info as pr_info_mod, pr_issue_link, pr_labels, pull as pull_mod, worktree as worktree_mod
|
|
24
26
|
from .ado_auth import auth_header
|
|
25
|
-
from .cli_tools import detect_agent_session, require_az, require_gh
|
|
27
|
+
from .cli_tools import CLI_TIMEOUT_SECS, detect_agent_session, require_az, require_gh
|
|
26
28
|
from .gitrepo import AdoRemote, GitHubRemote, UnknownRemoteError, current_branch, current_remote, head_commit_subject
|
|
27
29
|
|
|
28
30
|
# Non-ASCII output (checkmarks, en-dashes in ADO project names, etc.) needs a
|
|
@@ -34,6 +36,9 @@ if sys.platform == "win32":
|
|
|
34
36
|
|
|
35
37
|
__version__ = _pkg_version("bmsdna-devtools")
|
|
36
38
|
|
|
39
|
+
# What `--target` / `--target-branch` mean when not given: the branch PRs normally go into.
|
|
40
|
+
DEFAULT_TARGET_BRANCH = "dev"
|
|
41
|
+
|
|
37
42
|
app = typer.Typer(
|
|
38
43
|
name="bdt",
|
|
39
44
|
help=f"Shared BMS developer tooling: PRs/builds (Azure DevOps or GitHub), worktrees, commits, logs (v{__version__})",
|
|
@@ -115,6 +120,14 @@ def _resolve_ado_pr(pat: str | None, remote: AdoRemote, target: str, pr_id: int
|
|
|
115
120
|
return session, pr
|
|
116
121
|
|
|
117
122
|
|
|
123
|
+
def _current_pr_info(pr_id: int | None, target_branch: str, pat: str | None) -> pr_info_mod.PrInfo:
|
|
124
|
+
remote = current_remote()
|
|
125
|
+
if isinstance(remote, GitHubRemote):
|
|
126
|
+
return pr_info_mod.github_info(gh_pr.get_pr(require_gh(), pr_id, fields=gh_pr.PR_INFO_FIELDS), remote)
|
|
127
|
+
session, pr = _resolve_ado_pr(pat, remote, target_branch, pr_id=pr_id)
|
|
128
|
+
return pr_info_mod.ado_info(session, pr, remote)
|
|
129
|
+
|
|
130
|
+
|
|
118
131
|
def _gh_branch_for(gh: str, pr_id: int | None, *, needed: bool) -> str:
|
|
119
132
|
"""Branch to namespace uploaded screenshots/files under (see `gh_pr.push_assets`) --
|
|
120
133
|
the checked-out branch normally, or (when acting on a PR by `--pr-id`, which may not
|
|
@@ -219,7 +232,7 @@ def _link_and_label_ado(session: requests.Session, remote: AdoRemote, pr_id: int
|
|
|
219
232
|
|
|
220
233
|
@pr_app.command("create")
|
|
221
234
|
def pr_create(
|
|
222
|
-
target: str = typer.Option(
|
|
235
|
+
target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch (e.g. dev, main, test)"),
|
|
223
236
|
draft: bool = typer.Option(
|
|
224
237
|
True,
|
|
225
238
|
"--draft/--no-draft",
|
|
@@ -371,7 +384,7 @@ def pr_create(
|
|
|
371
384
|
|
|
372
385
|
@pr_app.command("publish")
|
|
373
386
|
def pr_publish(
|
|
374
|
-
target: str = typer.Option(
|
|
387
|
+
target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch of the PR (Azure DevOps only)"),
|
|
375
388
|
pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
|
|
376
389
|
pat: str | None = typer.Option(
|
|
377
390
|
None,
|
|
@@ -393,7 +406,7 @@ def pr_publish(
|
|
|
393
406
|
|
|
394
407
|
@pr_app.command("status")
|
|
395
408
|
def pr_status(
|
|
396
|
-
target_branch: str = typer.Option(
|
|
409
|
+
target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Target branch of the PR (Azure DevOps only — gh has no equivalent filter, it always resolves the PR for the current branch)"),
|
|
397
410
|
wait: bool = typer.Option(False, "--wait", help="Poll until all pipelines/checks are completed; stops early and reports status if one needs manual approval"),
|
|
398
411
|
pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
|
|
399
412
|
pat: str | None = typer.Option(None, "--pat", envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"], help="Azure DevOps PAT (else falls back to `az` login)"),
|
|
@@ -408,9 +421,30 @@ def pr_status(
|
|
|
408
421
|
pr_build.run(remote, pat, target_branch, wait, pr_id=pr_id)
|
|
409
422
|
|
|
410
423
|
|
|
424
|
+
@pr_app.command("info")
|
|
425
|
+
def pr_info(
|
|
426
|
+
as_json: bool = typer.Option(False, "--json", help="Print machine-readable JSON (for status integrations that poll it)"),
|
|
427
|
+
target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Target branch of the PR (Azure DevOps only)"),
|
|
428
|
+
pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
|
|
429
|
+
pat: str | None = typer.Option(None, "--pat", envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"], help="Azure DevOps PAT (else falls back to `az` login)"),
|
|
430
|
+
) -> None:
|
|
431
|
+
"""One-shot summary of the PR opened from the current branch, or --pr-id directly: link, state,
|
|
432
|
+
aggregate build state, and the issue(s)/work item(s) it closes. Exits 1 if there is no such PR.
|
|
433
|
+
Build state is judged the way `pr status` does (latest build per pipeline on Azure DevOps).
|
|
434
|
+
"""
|
|
435
|
+
info = _current_pr_info(pr_id, target_branch, pat)
|
|
436
|
+
if as_json:
|
|
437
|
+
typer.echo(json.dumps(info.to_json_dict()))
|
|
438
|
+
return
|
|
439
|
+
flags = ", ".join([info.state, *(["draft"] if info.draft else []), f"build: {info.build}"])
|
|
440
|
+
typer.echo(f"PR #{info.number}: {info.title} ({flags})\n{info.url}")
|
|
441
|
+
for issue in info.issues:
|
|
442
|
+
typer.echo(f"Issue #{issue.number}: {issue.url}")
|
|
443
|
+
|
|
444
|
+
|
|
411
445
|
@pr_app.command("retry")
|
|
412
446
|
def pr_retry(
|
|
413
|
-
target_branch: str = typer.Option(
|
|
447
|
+
target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Target branch of the PR (Azure DevOps only)"),
|
|
414
448
|
pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
|
|
415
449
|
pat: str | None = typer.Option(
|
|
416
450
|
None,
|
|
@@ -432,7 +466,7 @@ def pr_retry(
|
|
|
432
466
|
|
|
433
467
|
@pr_app.command("watch-deploy")
|
|
434
468
|
def pr_watch_deploy(
|
|
435
|
-
target_branch: str = typer.Option(
|
|
469
|
+
target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Branch to watch for a directly-triggered build/workflow run (e.g. a post-merge deployment pipeline)"),
|
|
436
470
|
wait: bool = typer.Option(False, "--wait", help="Poll until the build/workflow run(s) are completed; stops early and reports status if one needs manual approval"),
|
|
437
471
|
pat: str | None = typer.Option(
|
|
438
472
|
None,
|
|
@@ -467,7 +501,7 @@ def pr_update(
|
|
|
467
501
|
file: list[str] = typer.Option(
|
|
468
502
|
[], "--file", help="Path to an arbitrary file to append to the PR description as a linked attachment (repeatable)"
|
|
469
503
|
),
|
|
470
|
-
target: str = typer.Option(
|
|
504
|
+
target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch of the PR (Azure DevOps only)"),
|
|
471
505
|
pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
|
|
472
506
|
pat: str | None = typer.Option(
|
|
473
507
|
None,
|
|
@@ -510,7 +544,7 @@ def pr_comment(
|
|
|
510
544
|
file: list[str] = typer.Option(
|
|
511
545
|
[], "--file", help="Path to an arbitrary file to link in the comment as an attachment (repeatable)"
|
|
512
546
|
),
|
|
513
|
-
target: str = typer.Option(
|
|
547
|
+
target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch of the PR (Azure DevOps only)"),
|
|
514
548
|
pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
|
|
515
549
|
pat: str | None = typer.Option(
|
|
516
550
|
None,
|
|
@@ -717,7 +751,86 @@ def issue_do(
|
|
|
717
751
|
session = requests.Session()
|
|
718
752
|
session.headers.update(auth_header(pat))
|
|
719
753
|
title, body = issue_do_mod.fetch_ado(session, remote, number)
|
|
720
|
-
|
|
754
|
+
extra = list(ctx.args)
|
|
755
|
+
# claude can be given the session id up front, so the "Taken by" comment can link the session that will exist
|
|
756
|
+
session_id = str(uuid.uuid4()) if agent == "claude" and "--session-id" not in extra else None
|
|
757
|
+
if dry_run:
|
|
758
|
+
typer.echo(f"(dry run: would comment 'Taken by ...' on #{number} first)", err=True)
|
|
759
|
+
else:
|
|
760
|
+
_take(remote, number, issue_do_mod.take_message(_current_user(remote), agent, session_id), pat, agent_note=False)
|
|
761
|
+
issue_do_mod.run(number, title, body, agent=agent, extra=extra, dry_run=dry_run, session_id=session_id)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
@issue_app.command("take")
|
|
765
|
+
def issue_take(
|
|
766
|
+
number: int | None = typer.Argument(None, help="Issue number (GitHub) or work item ID (Azure DevOps). Default: the issue the current branch's PR closes"),
|
|
767
|
+
target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Target branch of the PR (Azure DevOps only; only used to find the issue when no number is given)"),
|
|
768
|
+
pat: str | None = typer.Option(None, "--pat", envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"], help="Azure DevOps PAT (else falls back to `az` login)"),
|
|
769
|
+
) -> None:
|
|
770
|
+
"""Claim an issue / work item: comment "Taken by <you>" on it, with this coding agent's
|
|
771
|
+
session link (detected automatically) so others can see who is working on it and find the session.
|
|
772
|
+
"""
|
|
773
|
+
remote = current_remote()
|
|
774
|
+
if number is None:
|
|
775
|
+
issues = _current_pr_info(None, target_branch, pat).issues
|
|
776
|
+
if len(issues) != 1:
|
|
777
|
+
raise typer.BadParameter(
|
|
778
|
+
f"The current branch's PR closes {len(issues)} issues, so can't tell which to take -- pass the number"
|
|
779
|
+
if issues
|
|
780
|
+
else "No issue number given, and the current branch's PR doesn't reference one (e.g. 'Fixes #N')"
|
|
781
|
+
)
|
|
782
|
+
number = issues[0].number
|
|
783
|
+
_take(remote, number, f"{issue_do_mod.TAKEN_PREFIX} {_current_user(remote)}", pat)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _take(remote: AdoRemote | GitHubRemote, number: int, message: str, pat: str | None, *, agent_note: bool = True) -> None:
|
|
787
|
+
"""Comment `message` ("Taken by ...") on the issue -- unless its newest comment already is such a
|
|
788
|
+
claim, so running this twice (or `issue do` after `issue take`) doesn't post it again."""
|
|
789
|
+
kind = "issue" if isinstance(remote, GitHubRemote) else "work item"
|
|
790
|
+
if issue_do_mod.is_take_comment(_last_comment(remote, number, pat)):
|
|
791
|
+
typer.echo(f"{kind.capitalize()} #{number} is already taken (its last comment says so) -- not commenting again")
|
|
792
|
+
return
|
|
793
|
+
typer.echo(f"Taking {kind} #{number}")
|
|
794
|
+
_comment_on_issue(remote, number, message, pat, agent_note=agent_note)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _last_comment(remote: AdoRemote | GitHubRemote, number: int, pat: str | None) -> str | None:
|
|
798
|
+
if isinstance(remote, GitHubRemote):
|
|
799
|
+
return gh_issue.last_comment(require_gh(), remote.owner, remote.repo, number)
|
|
800
|
+
session = requests.Session()
|
|
801
|
+
session.headers.update(auth_header(pat))
|
|
802
|
+
return ado_issue.last_comment(session, remote, number)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _comment_on_issue(remote: AdoRemote | GitHubRemote, number: int, message: str, pat: str | None, *, agent_note: bool = True) -> None:
|
|
806
|
+
if isinstance(remote, GitHubRemote):
|
|
807
|
+
gh_issue.comment(require_gh(), remote.owner, remote.repo, number, message, [], agent_note=agent_note)
|
|
808
|
+
else:
|
|
809
|
+
session = requests.Session()
|
|
810
|
+
session.headers.update(auth_header(pat))
|
|
811
|
+
ado_issue.comment_with_screenshots(session, remote, number, message, [], [], agent_note=agent_note)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _current_user(remote: AdoRemote | GitHubRemote) -> str:
|
|
815
|
+
"""Who to say took an issue: the GitHub login `gh` is authenticated as, else the git `user.name`."""
|
|
816
|
+
if isinstance(remote, GitHubRemote):
|
|
817
|
+
r = _capture([require_gh(), "api", "user", "--jq", ".login"])
|
|
818
|
+
if r and r.returncode == 0 and r.stdout.strip():
|
|
819
|
+
return r.stdout.strip()
|
|
820
|
+
r = _capture(["git", "config", "user.name"])
|
|
821
|
+
# `git config user.name` comes from the (possibly cloned-from-elsewhere) repo's own config and ends up in a
|
|
822
|
+
# markdown/HTML comment, so keep it to plain name characters: no links, tags or @mentions
|
|
823
|
+
name = re.sub(r"[^\w .'-]", "", r.stdout if r else "").strip()[:64]
|
|
824
|
+
return name or "unknown"
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _capture(cmd: list[str]) -> subprocess.CompletedProcess | None:
|
|
828
|
+
"""Run `cmd` for its UTF-8 output, bounded so a stalled `gh` (expired login prompting) can't hang the
|
|
829
|
+
caller; None on timeout."""
|
|
830
|
+
try:
|
|
831
|
+
return subprocess.run(cmd, capture_output=True, encoding="utf-8", timeout=CLI_TIMEOUT_SECS, check=False)
|
|
832
|
+
except subprocess.TimeoutExpired:
|
|
833
|
+
return None
|
|
721
834
|
|
|
722
835
|
|
|
723
836
|
@issue_app.command("update")
|
|
@@ -1116,7 +1229,7 @@ def commit(
|
|
|
1116
1229
|
subrepo: list[str] = typer.Option([], "--subrepo", help="Submodule directory name to split matching files into (repeatable)"),
|
|
1117
1230
|
skip_message_check: bool = typer.Option(False, "--skip-message-check", help="Don't require a conventional-commit-style message"),
|
|
1118
1231
|
allow_main: bool = typer.Option(False, "--allow-main", help="Allow committing directly on main/master"),
|
|
1119
|
-
target: str = typer.Option(
|
|
1232
|
+
target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch of the PR to draft on a 'feat' commit (Azure DevOps only)"),
|
|
1120
1233
|
pat: str | None = typer.Option(
|
|
1121
1234
|
None,
|
|
1122
1235
|
"--pat",
|
|
@@ -366,12 +366,19 @@ def comment(
|
|
|
366
366
|
message: str | None,
|
|
367
367
|
screenshot_paths: list[str],
|
|
368
368
|
file_paths: list[str] | None = None,
|
|
369
|
+
agent_note: bool = True,
|
|
369
370
|
) -> None:
|
|
370
|
-
"""Post a comment, with a message and/or screenshots/files, on a GitHub issue.
|
|
371
|
+
"""Post a comment, with a message and/or screenshots/files, on a GitHub issue.
|
|
372
|
+
|
|
373
|
+
`agent_note=False` leaves out the detected agent-session note, for a caller (`bdt issue do`) that
|
|
374
|
+
is not itself the agent and writes the right session into `message` instead.
|
|
375
|
+
"""
|
|
371
376
|
file_paths = file_paths or []
|
|
372
377
|
images = _screenshot_images(owner, repo, f"issue-{number}", screenshot_paths) if screenshot_paths else []
|
|
373
378
|
files = _file_links(owner, repo, f"issue-{number}", file_paths) if file_paths else []
|
|
374
|
-
content =
|
|
379
|
+
content = build_comment_content(message, images, files) or ""
|
|
380
|
+
if agent_note:
|
|
381
|
+
content = ensure_agent_session_note(content) or ""
|
|
375
382
|
url = _run_gh(gh, ["issue", "comment", str(number), "--body", content])
|
|
376
383
|
comment_id = parse_comment_id(url)
|
|
377
384
|
suffix = f" (comment #{comment_id})" if comment_id else ""
|
|
@@ -379,6 +386,19 @@ def comment(
|
|
|
379
386
|
print(url)
|
|
380
387
|
|
|
381
388
|
|
|
389
|
+
_LAST_COMMENT_QUERY = (
|
|
390
|
+
"query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) "
|
|
391
|
+
"{ issue(number: $number) { comments(last: 1) { nodes { body } } } } }"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def last_comment(gh: str, owner: str, repo: str, number: int) -> str | None:
|
|
396
|
+
"""Text of the issue's newest comment, or None if it has none."""
|
|
397
|
+
out = _run_gh(gh, ["api", "graphql", "-f", f"query={_LAST_COMMENT_QUERY}", "-f", f"owner={owner}", "-f", f"repo={repo}", "-F", f"number={number}"])
|
|
398
|
+
nodes = json.loads(out)["data"]["repository"]["issue"]["comments"]["nodes"]
|
|
399
|
+
return nodes[-1]["body"] if nodes else None
|
|
400
|
+
|
|
401
|
+
|
|
382
402
|
def update_comment(gh: str, owner: str, repo: str, comment_id: str, text: str) -> None:
|
|
383
403
|
_run_gh(gh, ["api", "--method", "PATCH", f"repos/{owner}/{repo}/issues/comments/{comment_id}", "-f", f"body={text}"])
|
|
384
404
|
print(f"Updated comment #{comment_id}")
|
|
@@ -23,6 +23,8 @@ from .cli_tools import CLI_TIMEOUT_SECS, EXIT_NEEDS_APPROVAL, PollHeartbeat, det
|
|
|
23
23
|
from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section
|
|
24
24
|
|
|
25
25
|
PR_VIEW_FIELDS = "number,title,baseRefName,headRefName,mergeable,statusCheckRollup,isDraft"
|
|
26
|
+
# What `bdt pr info` needs on top of the check rollup: where to link to, and the issues GitHub says it closes.
|
|
27
|
+
PR_INFO_FIELDS = "number,title,url,state,isDraft,closingIssuesReferences,statusCheckRollup"
|
|
26
28
|
|
|
27
29
|
# GitHub has no API for uploading images to a PR description (only the web
|
|
28
30
|
# UI's drag-and-drop, which needs a browser session). The standard
|
|
@@ -99,14 +101,16 @@ def _pr_id_args(pr_id: int | None) -> list[str]:
|
|
|
99
101
|
return [str(pr_id)] if pr_id is not None else []
|
|
100
102
|
|
|
101
103
|
|
|
102
|
-
def get_pr(gh: str, pr_id: int | None = None) -> dict:
|
|
104
|
+
def get_pr(gh: str, pr_id: int | None = None, fields: str = PR_VIEW_FIELDS) -> dict:
|
|
103
105
|
"""The PR for the current branch, however `gh` resolves it — there's no
|
|
104
106
|
target-branch filter on `gh pr view` the way ADO's search API has one.
|
|
105
107
|
|
|
106
108
|
If `pr_id` is given, resolves that PR directly instead -- lets a caller act on a
|
|
107
109
|
specific PR without needing its branch checked out locally at all.
|
|
110
|
+
|
|
111
|
+
`fields` is the `--json` field list; defaults to what `bdt pr status` needs.
|
|
108
112
|
"""
|
|
109
|
-
args = ["pr", "view", *_pr_id_args(pr_id), "--json",
|
|
113
|
+
args = ["pr", "view", *_pr_id_args(pr_id), "--json", fields]
|
|
110
114
|
return _run_gh_json(gh, args)
|
|
111
115
|
|
|
112
116
|
|
|
@@ -19,6 +19,7 @@ from .ado_issue import _base_url
|
|
|
19
19
|
from .gitrepo import AdoRemote
|
|
20
20
|
|
|
21
21
|
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
|
22
|
+
TAKEN_PREFIX = "Taken by"
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
def fetch_github(gh: str, number: int) -> tuple[str, str]:
|
|
@@ -49,20 +50,37 @@ def build_prompt(number: int, title: str, body: str) -> str:
|
|
|
49
50
|
return (
|
|
50
51
|
f"Work on issue {number}: {title}\n\n{body.strip() or '(no description)'}\n\n"
|
|
51
52
|
"Follow the dev-workflow skill: worktree, draft PR early, implement, test, publish the PR "
|
|
52
|
-
f"and reference issue {number} in it. Do not merge."
|
|
53
|
+
f"and reference issue {number} in it. Do not merge. `bdt issue do` has already marked "
|
|
54
|
+
f"issue {number} as taken (with this session), so don't run `bdt issue take`."
|
|
53
55
|
)
|
|
54
56
|
|
|
55
57
|
|
|
56
|
-
def
|
|
58
|
+
def is_take_comment(text: str | None) -> bool:
|
|
59
|
+
"""Is `text` (a comment, markdown or ADO HTML) a "Taken by ..." claim -- what `take_message` and
|
|
60
|
+
`bdt issue take` post?"""
|
|
61
|
+
return _HTML_TAG_RE.sub("", text or "").strip().startswith(TAKEN_PREFIX)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def take_message(user: str, agent: str, session_id: str | None) -> str:
|
|
65
|
+
"""The "Taken by" comment `bdt issue do` posts before the agent starts. It names the session itself,
|
|
66
|
+
because the agent's own session doesn't exist yet and `bdt`'s auto-detected one (if `bdt issue do` is
|
|
67
|
+
run from inside an agent) would be the wrong one."""
|
|
68
|
+
if session_id is None:
|
|
69
|
+
return f"{TAKEN_PREFIX} {user} (via {agent})"
|
|
70
|
+
return f"{TAKEN_PREFIX} {user}\n\nClaude Session: {session_id} (resume with `claude --resume {session_id}`)"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_command(agent: str, number: int, title: str, body: str, extra: list[str], session_id: str | None = None) -> list[str]:
|
|
57
74
|
prompt = build_prompt(number, title, body)
|
|
58
75
|
if agent == "claude":
|
|
59
|
-
|
|
76
|
+
session = ["--session-id", session_id] if session_id else []
|
|
77
|
+
return [agent, "-p", prompt, "--name", session_name(number, title), *session, *extra]
|
|
60
78
|
# Other agents: no known naming flag, so just hand over the prompt.
|
|
61
79
|
return [agent, *extra, prompt]
|
|
62
80
|
|
|
63
81
|
|
|
64
|
-
def run(number: int, title: str, body: str, *, agent: str, extra: list[str], dry_run: bool) -> None:
|
|
65
|
-
cmd = build_command(agent, number, title, body, extra)
|
|
82
|
+
def run(number: int, title: str, body: str, *, agent: str, extra: list[str], dry_run: bool, session_id: str | None = None) -> None:
|
|
83
|
+
cmd = build_command(agent, number, title, body, extra, session_id)
|
|
66
84
|
if dry_run:
|
|
67
85
|
print(" ".join(json.dumps(c) if " " in c or "\n" in c else c for c in cmd))
|
|
68
86
|
return
|