bmsdna-devtools 0.22.0__tar.gz → 0.23.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.22.0/README.md → bmsdna_devtools-0.23.0/PKG-INFO +28 -0
- bmsdna_devtools-0.22.0/PKG-INFO → bmsdna_devtools-0.23.0/README.md +17 -11
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/ado_issue.py +34 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/cli.py +118 -3
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/gh_issue.py +35 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/gh_pr.py +32 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/gitrepo.py +6 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/pr_build.py +15 -0
- bmsdna_devtools-0.23.0/bmsdna/devtools/pr_issue_link.py +108 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/pr_labels.py +32 -0
- bmsdna_devtools-0.23.0/bmsdna/devtools/pull.py +286 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/worktree.py +21 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_ado_issue.py +58 -0
- bmsdna_devtools-0.23.0/tests/test_cli_pr_create.py +569 -0
- bmsdna_devtools-0.23.0/tests/test_cli_pull.py +45 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_gh_issue.py +58 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_gh_pr.py +82 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_gitrepo.py +19 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_pr_build.py +27 -0
- bmsdna_devtools-0.23.0/tests/test_pr_issue_link.py +158 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_pr_labels.py +52 -1
- bmsdna_devtools-0.23.0/tests/test_pull.py +390 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_worktree_clean.py +64 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/uv.lock +1 -1
- bmsdna_devtools-0.22.0/tests/test_cli_pr_create.py +0 -236
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/.gitignore +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/.python-version +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/justfile +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_testdb.py +0 -0
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bmsdna-devtools
|
|
3
|
+
Version: 0.23.0
|
|
4
|
+
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
|
+
Requires-Python: >=3.14
|
|
6
|
+
Requires-Dist: pgdevkit[db]>=0.7.1
|
|
7
|
+
Requires-Dist: requests>=2.32.0
|
|
8
|
+
Requires-Dist: rich>=13.0.0
|
|
9
|
+
Requires-Dist: typer>=0.26.7
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
1
12
|
# bmsdna-devtools
|
|
2
13
|
|
|
3
14
|
Shared developer tooling for BMS projects: PR build/check status, PR
|
|
@@ -160,6 +171,23 @@ With the above, `bdt pr create --label feature --label breaking` passes,
|
|
|
160
171
|
but `bdt pr create --label feature` fails with a message naming the unmet
|
|
161
172
|
group (`risk`) and its allowed choices.
|
|
162
173
|
|
|
174
|
+
`[tool.bdt.pr.scope_labels]` in pyproject.toml can auto-apply a label based on
|
|
175
|
+
the "scope" of the HEAD commit's conventional-commit subject (the
|
|
176
|
+
`type(scope): description` format `bdt commit` itself expects, e.g.
|
|
177
|
+
`feat(customers): ...`):
|
|
178
|
+
|
|
179
|
+
```toml
|
|
180
|
+
[tool.bdt.pr.scope_labels]
|
|
181
|
+
customers = "e2e-customers"
|
|
182
|
+
billing = "e2e-billing"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
With the above, creating a PR whose HEAD commit is `feat(customers): add
|
|
186
|
+
widget` automatically adds the `e2e-customers` label (merged with, not
|
|
187
|
+
replacing, any explicit `--label`). No match, no `[tool.bdt.pr.scope_labels]`
|
|
188
|
+
table, or a commit subject without a `(scope)` all mean no label gets added —
|
|
189
|
+
opt-in, so repos that don't configure it see no change in behavior.
|
|
190
|
+
|
|
163
191
|
If `--target` has a build policy configured (an Azure DevOps Build policy,
|
|
164
192
|
or a GitHub branch protection rule requiring status checks), a successful
|
|
165
193
|
create prints a reminder to run `bdt pr status` afterward to check whether
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: bmsdna-devtools
|
|
3
|
-
Version: 0.22.0
|
|
4
|
-
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
|
-
Requires-Python: >=3.14
|
|
6
|
-
Requires-Dist: pgdevkit[db]>=0.7.1
|
|
7
|
-
Requires-Dist: requests>=2.32.0
|
|
8
|
-
Requires-Dist: rich>=13.0.0
|
|
9
|
-
Requires-Dist: typer>=0.26.7
|
|
10
|
-
Description-Content-Type: text/markdown
|
|
11
|
-
|
|
12
1
|
# bmsdna-devtools
|
|
13
2
|
|
|
14
3
|
Shared developer tooling for BMS projects: PR build/check status, PR
|
|
@@ -171,6 +160,23 @@ With the above, `bdt pr create --label feature --label breaking` passes,
|
|
|
171
160
|
but `bdt pr create --label feature` fails with a message naming the unmet
|
|
172
161
|
group (`risk`) and its allowed choices.
|
|
173
162
|
|
|
163
|
+
`[tool.bdt.pr.scope_labels]` in pyproject.toml can auto-apply a label based on
|
|
164
|
+
the "scope" of the HEAD commit's conventional-commit subject (the
|
|
165
|
+
`type(scope): description` format `bdt commit` itself expects, e.g.
|
|
166
|
+
`feat(customers): ...`):
|
|
167
|
+
|
|
168
|
+
```toml
|
|
169
|
+
[tool.bdt.pr.scope_labels]
|
|
170
|
+
customers = "e2e-customers"
|
|
171
|
+
billing = "e2e-billing"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
With the above, creating a PR whose HEAD commit is `feat(customers): add
|
|
175
|
+
widget` automatically adds the `e2e-customers` label (merged with, not
|
|
176
|
+
replacing, any explicit `--label`). No match, no `[tool.bdt.pr.scope_labels]`
|
|
177
|
+
table, or a commit subject without a `(scope)` all mean no label gets added —
|
|
178
|
+
opt-in, so repos that don't configure it see no change in behavior.
|
|
179
|
+
|
|
174
180
|
If `--target` has a build policy configured (an Azure DevOps Build policy,
|
|
175
181
|
or a GitHub branch protection rule requiring status checks), a successful
|
|
176
182
|
create prints a reminder to run `bdt pr status` afterward to check whether
|
|
@@ -49,6 +49,7 @@ import requests
|
|
|
49
49
|
from .bdt_config import load_bdt_table
|
|
50
50
|
from .cli_tools import ensure_agent_session_note
|
|
51
51
|
from .gitrepo import AdoRemote
|
|
52
|
+
from .pr_issue_link import PR_AVAILABLE_LABEL
|
|
52
53
|
from .pr_markdown import build_comment_content_html
|
|
53
54
|
|
|
54
55
|
COMMENTS_API_VERSION = "7.1-preview.4"
|
|
@@ -204,6 +205,39 @@ def get_valid_states(session: requests.Session, remote: AdoRemote, work_item_typ
|
|
|
204
205
|
return [s["name"] for s in r.json()["value"]]
|
|
205
206
|
|
|
206
207
|
|
|
208
|
+
def get_work_item_tags(session: requests.Session, remote: AdoRemote, work_item_id: int) -> list[str]:
|
|
209
|
+
r = session.get(
|
|
210
|
+
f"{_base_url(remote)}/_apis/wit/workitems/{work_item_id}",
|
|
211
|
+
params={"fields": "System.Tags", "api-version": "7.1"},
|
|
212
|
+
)
|
|
213
|
+
if r.status_code == 404:
|
|
214
|
+
sys.exit(f"Work item #{work_item_id} not found in project '{remote.project}'.")
|
|
215
|
+
r.raise_for_status()
|
|
216
|
+
raw = r.json().get("fields", {}).get("System.Tags") or ""
|
|
217
|
+
return [t.strip() for t in raw.split(";") if t.strip()]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def add_tag(session: requests.Session, remote: AdoRemote, work_item_id: int, tag: str) -> None:
|
|
221
|
+
"""Add `tag` to work item `work_item_id`'s tags, alongside whatever's already there.
|
|
222
|
+
|
|
223
|
+
`update_work_item`'s tags op replaces the whole `System.Tags` field (there's no
|
|
224
|
+
Azure DevOps "add one tag" patch op), so this reads the current tags first and only
|
|
225
|
+
PATCHes if `tag` isn't already among them -- otherwise a second `pr create --issue`
|
|
226
|
+
against the same work item would keep re-sending an unchanged value.
|
|
227
|
+
"""
|
|
228
|
+
tags = get_work_item_tags(session, remote, work_item_id)
|
|
229
|
+
if any(t.casefold() == tag.casefold() for t in tags):
|
|
230
|
+
return
|
|
231
|
+
update_work_item(session, remote, work_item_id, tags=[*tags, tag])
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def add_pr_available_tag(session: requests.Session, remote: AdoRemote, work_item_id: int) -> None:
|
|
235
|
+
"""Tag work item `work_item_id` `pr-available` -- the Azure DevOps equivalent of the
|
|
236
|
+
GitHub `pr-available` label (Azure DevOps work items have no label concept; tags are the
|
|
237
|
+
closest equivalent, same as `ado_issue.py`'s own `--tag` create/update flag)."""
|
|
238
|
+
add_tag(session, remote, work_item_id, PR_AVAILABLE_LABEL)
|
|
239
|
+
|
|
240
|
+
|
|
207
241
|
def delete_work_item(session: requests.Session, remote: AdoRemote, work_item_id: int) -> None:
|
|
208
242
|
"""Soft-delete: moves the work item to the project's Recycle Bin, where it can be restored.
|
|
209
243
|
|
|
@@ -17,10 +17,10 @@ from . import env_config
|
|
|
17
17
|
from . import find_repo as find_repo_mod
|
|
18
18
|
from . import gh_issue, gh_pr
|
|
19
19
|
from . import logs as logs_mod
|
|
20
|
-
from . import pr_build, pr_labels, worktree as worktree_mod
|
|
20
|
+
from . import pr_build, pr_issue_link, pr_labels, pull as pull_mod, worktree as worktree_mod
|
|
21
21
|
from .ado_auth import auth_header
|
|
22
22
|
from .cli_tools import detect_agent_session, require_az, require_gh
|
|
23
|
-
from .gitrepo import AdoRemote, GitHubRemote, current_branch, current_remote
|
|
23
|
+
from .gitrepo import AdoRemote, GitHubRemote, current_branch, current_remote, head_commit_subject
|
|
24
24
|
|
|
25
25
|
# Non-ASCII output (checkmarks, en-dashes in ADO project names, etc.) needs a
|
|
26
26
|
# UTF-8 stream — the default Windows console codepage isn't UTF-8, and would
|
|
@@ -120,6 +120,53 @@ def _after_create(step: Callable[[], None], label: str) -> None:
|
|
|
120
120
|
print(f"Warning: PR created, but {label} failed: {e}")
|
|
121
121
|
|
|
122
122
|
|
|
123
|
+
def _link_and_label_github(gh: str, remote: GitHubRemote, issue_numbers: list[int]) -> None:
|
|
124
|
+
"""Link every issue in `issue_numbers`, plus every issue `find_issue_refs_in_body` finds in
|
|
125
|
+
the PR's actual body, to the current branch's PR, and label each `pr-available`.
|
|
126
|
+
|
|
127
|
+
Best-effort per issue -- one bad/inaccessible issue number shouldn't stop the others from
|
|
128
|
+
being linked/labeled; failures are collected and surfaced together to the caller (which
|
|
129
|
+
wraps this in `_after_create`, so they're reported as a warning, not a failed `pr create`).
|
|
130
|
+
A no-op (no `gh` calls at all) when there's nothing to link.
|
|
131
|
+
"""
|
|
132
|
+
pr_number, body = gh_pr.get_pr_body(gh)
|
|
133
|
+
all_numbers = list(dict.fromkeys([*issue_numbers, *pr_issue_link.find_issue_refs_in_body(body, remote)]))
|
|
134
|
+
if not all_numbers:
|
|
135
|
+
return
|
|
136
|
+
gh_issue.ensure_pr_available_label(gh)
|
|
137
|
+
errors: list[str] = []
|
|
138
|
+
for number in all_numbers:
|
|
139
|
+
try:
|
|
140
|
+
body = gh_pr.link_issue_to_pr(gh, pr_number, body, number)
|
|
141
|
+
gh_issue.add_pr_available_label(gh, number)
|
|
142
|
+
except SystemExit as e:
|
|
143
|
+
errors.append(f"#{number}: {e}")
|
|
144
|
+
if errors:
|
|
145
|
+
raise RuntimeError("; ".join(errors))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _link_and_label_ado(session: requests.Session, remote: AdoRemote, pr_id: int, description: str, issue_numbers: list[int]) -> None:
|
|
149
|
+
"""Azure DevOps equivalent of `_link_and_label_github`: link every issue in `issue_numbers`,
|
|
150
|
+
plus every work item `find_issue_refs_in_body` finds in the PR's description, to PR `pr_id`,
|
|
151
|
+
and tag each `pr-available`.
|
|
152
|
+
|
|
153
|
+
Best-effort per work item -- one bad/inaccessible number shouldn't stop the others from being
|
|
154
|
+
linked/tagged; failures (a REST error, or a 404 `sys.exit` from deeper in `ado_issue.py`) are
|
|
155
|
+
collected and surfaced together to the caller (which wraps this in `_after_create`, so they're
|
|
156
|
+
reported as a warning, not a failed `pr create`).
|
|
157
|
+
"""
|
|
158
|
+
all_numbers = list(dict.fromkeys([*issue_numbers, *pr_issue_link.find_issue_refs_in_body(description, remote)]))
|
|
159
|
+
errors: list[str] = []
|
|
160
|
+
for number in all_numbers:
|
|
161
|
+
try:
|
|
162
|
+
pr_build.link_work_item(session, remote, pr_id, number)
|
|
163
|
+
ado_issue.add_pr_available_tag(session, remote, number)
|
|
164
|
+
except (requests.RequestException, SystemExit) as e:
|
|
165
|
+
errors.append(f"#{number}: {e}")
|
|
166
|
+
if errors:
|
|
167
|
+
raise RuntimeError("; ".join(errors))
|
|
168
|
+
|
|
169
|
+
|
|
123
170
|
@pr_app.command("create")
|
|
124
171
|
def pr_create(
|
|
125
172
|
target: str = typer.Option("main", "--target", help="Target branch (e.g. main, test)"),
|
|
@@ -134,7 +181,8 @@ def pr_create(
|
|
|
134
181
|
help="Label to apply to the PR (repeatable). On GitHub the label must already exist on the repo "
|
|
135
182
|
"(`gh label create`); Azure DevOps PR labels are freeform and created on the fly. "
|
|
136
183
|
r"[tool.bdt.pr.required_labels] in pyproject.toml can require at least one label from each "
|
|
137
|
-
"named group before the PR is created."
|
|
184
|
+
"named group before the PR is created. [tool.bdt.pr.scope_labels] can auto-add a label based "
|
|
185
|
+
"on the HEAD commit's conventional-commit scope (e.g. `feat(customers): ...` -> a configured label).",
|
|
138
186
|
),
|
|
139
187
|
screenshot: list[str] = typer.Option(
|
|
140
188
|
[], "--screenshot", help="Path to an image to attach to the PR description (repeatable)"
|
|
@@ -142,6 +190,14 @@ def pr_create(
|
|
|
142
190
|
file: list[str] = typer.Option(
|
|
143
191
|
[], "--file", help="Path to an arbitrary file to attach to the PR description as a linked attachment (repeatable)"
|
|
144
192
|
),
|
|
193
|
+
issue: list[str] = typer.Option(
|
|
194
|
+
[],
|
|
195
|
+
"--issue",
|
|
196
|
+
help="Issue number or URL (GitHub issue or Azure DevOps work item) to link to this PR (repeatable). "
|
|
197
|
+
"Each linked issue/work item is also labeled/tagged 'pr-available'. Independent of this flag, the PR "
|
|
198
|
+
"body is scanned for 'Fixes/Closes/Resolves #NR' and full issue/work-item URLs; every match found "
|
|
199
|
+
"there gets the same link + label/tag treatment automatically.",
|
|
200
|
+
),
|
|
145
201
|
pat: str | None = typer.Option(
|
|
146
202
|
None,
|
|
147
203
|
"--pat",
|
|
@@ -158,12 +214,30 @@ def pr_create(
|
|
|
158
214
|
if not Path(path).is_file():
|
|
159
215
|
raise typer.BadParameter(f"File not found: {path}", param_hint="--file")
|
|
160
216
|
|
|
217
|
+
scope_label_map = pr_labels.scope_labels()
|
|
218
|
+
if scope_label_map:
|
|
219
|
+
scope = commit_mod.conventional_commit_scope(head_commit_subject())
|
|
220
|
+
auto_label = pr_labels.label_for_scope(scope_label_map, scope)
|
|
221
|
+
if auto_label and auto_label.lower() not in {existing.lower() for existing in label}:
|
|
222
|
+
print(f"Auto-applying label '{auto_label}' for scope '{scope}' ([tool.bdt.pr.scope_labels])")
|
|
223
|
+
label = [*label, auto_label]
|
|
224
|
+
|
|
161
225
|
missing_groups = pr_labels.missing_label_groups(pr_labels.required_label_groups(), label)
|
|
162
226
|
if missing_groups:
|
|
163
227
|
raise typer.BadParameter(pr_labels.format_missing_groups_error(missing_groups), param_hint="--label")
|
|
164
228
|
|
|
165
229
|
remote = current_remote()
|
|
230
|
+
|
|
231
|
+
issue_numbers: list[int] = []
|
|
232
|
+
for ref in issue:
|
|
233
|
+
try:
|
|
234
|
+
issue_numbers.append(pr_issue_link.parse_issue_ref(ref, remote))
|
|
235
|
+
except ValueError as e:
|
|
236
|
+
raise typer.BadParameter(str(e), param_hint="--issue") from e
|
|
237
|
+
issue_numbers = list(dict.fromkeys(issue_numbers))
|
|
238
|
+
|
|
166
239
|
source_branch = current_branch()
|
|
240
|
+
|
|
167
241
|
pr_url: str | None = None
|
|
168
242
|
if isinstance(remote, GitHubRemote):
|
|
169
243
|
gh = require_gh()
|
|
@@ -171,6 +245,8 @@ def pr_create(
|
|
|
171
245
|
build_policy = gh_pr.has_build_policy(gh, target)
|
|
172
246
|
if returncode == 0 and (screenshot or file):
|
|
173
247
|
_after_create(lambda: gh_pr.add_attachments(gh, remote.owner, remote.repo, source_branch, screenshot, file), "attaching screenshots/files")
|
|
248
|
+
if returncode == 0:
|
|
249
|
+
_after_create(lambda: _link_and_label_github(gh, remote, issue_numbers), "linking issue(s) / setting 'pr-available' label")
|
|
174
250
|
else:
|
|
175
251
|
az = require_az()
|
|
176
252
|
cmd = [
|
|
@@ -219,6 +295,17 @@ def pr_create(
|
|
|
219
295
|
pr_build.ensure_session_note(session, remote, pr)
|
|
220
296
|
|
|
221
297
|
_after_create(_finish, "attaching screenshots/files and/or noting the agent session")
|
|
298
|
+
if returncode == 0:
|
|
299
|
+
# Fetches the PR fresh via `pr_build.get_pr` rather than trusting `pr_json` -- `az`
|
|
300
|
+
# can exit 0 with stdout that isn't clean JSON (e.g. a deprecation banner ahead of
|
|
301
|
+
# it), which already leaves `pr_json` as None above; gating issue-linking on it too
|
|
302
|
+
# would then silently skip it instead of at least warning, unlike every other
|
|
303
|
+
# best-effort step here.
|
|
304
|
+
def _link_issues() -> None:
|
|
305
|
+
pr = pr_build.get_pr(session, remote, source_branch, target)
|
|
306
|
+
_link_and_label_ado(session, remote, pr["pullRequestId"], pr.get("description") or "", issue_numbers)
|
|
307
|
+
|
|
308
|
+
_after_create(_link_issues, "linking work item(s) / setting 'pr-available' tag")
|
|
222
309
|
|
|
223
310
|
if returncode == 0 and pr_url:
|
|
224
311
|
print(f"\n{pr_url}")
|
|
@@ -639,6 +726,34 @@ def worktree(
|
|
|
639
726
|
worktree_mod.create(name, base=base, env_file=env_file, submodules=submodules, install_cmd=install_cmd)
|
|
640
727
|
|
|
641
728
|
|
|
729
|
+
@app.command()
|
|
730
|
+
def pull(
|
|
731
|
+
remote: str = typer.Option(
|
|
732
|
+
"origin",
|
|
733
|
+
"--remote",
|
|
734
|
+
help="Remote to pull main/master and the default branch from (the current branch's own tracking "
|
|
735
|
+
"branch step always follows its real configured upstream, regardless of this)",
|
|
736
|
+
),
|
|
737
|
+
no_default: bool = typer.Option(
|
|
738
|
+
False,
|
|
739
|
+
"--no-default",
|
|
740
|
+
help="Don't also pull the repo's DEFAULT branch -- skip this when the current branch was already "
|
|
741
|
+
"branched from main/master, since pulling it again would be redundant",
|
|
742
|
+
),
|
|
743
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print what would be pulled, without doing it"),
|
|
744
|
+
pull_args: list[str] = typer.Argument(
|
|
745
|
+
None,
|
|
746
|
+
help="Extra flags passed through to every `git pull` step, e.g. `-- --rebase --ff-only` (put them after `--`)",
|
|
747
|
+
),
|
|
748
|
+
) -> None:
|
|
749
|
+
"""Bring the current branch up to date from three sources: its own remote tracking branch, the
|
|
750
|
+
remote's main/master branch, and the repo's DEFAULT branch (unless --no-default) -- each a
|
|
751
|
+
separate `git pull`, stopping with a clear error (and the exact command to re-run) if any step
|
|
752
|
+
hits a merge conflict.
|
|
753
|
+
"""
|
|
754
|
+
pull_mod.run(remote=remote, no_default=no_default, dry_run=dry_run, pull_args=pull_args or [])
|
|
755
|
+
|
|
756
|
+
|
|
642
757
|
@app.command("find-repo")
|
|
643
758
|
def find_repo_cmd(
|
|
644
759
|
name: str,
|
|
@@ -27,6 +27,7 @@ from pathlib import Path
|
|
|
27
27
|
from .bdt_config import load_bdt_table
|
|
28
28
|
from .cli_tools import ensure_agent_session_note
|
|
29
29
|
from .gh_pr import push_assets
|
|
30
|
+
from .pr_issue_link import PR_AVAILABLE_LABEL
|
|
30
31
|
from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section
|
|
31
32
|
|
|
32
33
|
_COMMENT_ID_RE = re.compile(r"#issuecomment-(\d+)")
|
|
@@ -166,6 +167,40 @@ def create(
|
|
|
166
167
|
print(f"Attached {len(screenshot_paths)} screenshot(s), {len(file_paths)} file(s) to issue #{number}")
|
|
167
168
|
|
|
168
169
|
|
|
170
|
+
def _label_exists(gh: str, name: str) -> bool:
|
|
171
|
+
out = _run_gh(gh, ["label", "list", "--search", name, "--json", "name"])
|
|
172
|
+
names = {label["name"].casefold() for label in (json.loads(out) if out else [])}
|
|
173
|
+
return name.casefold() in names
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def ensure_pr_available_label(gh: str) -> None:
|
|
177
|
+
"""Make sure the repo-wide `pr-available` label exists, creating it if not -- unlike Azure
|
|
178
|
+
DevOps tags (freeform), `gh issue edit --add-label` rejects a label name GitHub doesn't
|
|
179
|
+
already know about (see `gh_pr.create`'s docstring). Only creates the label when it's
|
|
180
|
+
actually missing, so an existing custom color/description isn't clobbered.
|
|
181
|
+
|
|
182
|
+
Call this once per `pr create` invocation, before any `add_pr_available_label` calls --
|
|
183
|
+
it's the same repo-wide label being checked/created each time, not a per-issue thing, so a
|
|
184
|
+
caller linking several issues in one go should only pay for this lookup once.
|
|
185
|
+
"""
|
|
186
|
+
if not _label_exists(gh, PR_AVAILABLE_LABEL):
|
|
187
|
+
_run_gh(
|
|
188
|
+
gh,
|
|
189
|
+
[
|
|
190
|
+
"label", "create", PR_AVAILABLE_LABEL,
|
|
191
|
+
"--color", "0E8A16",
|
|
192
|
+
"--description", "A PR exists that addresses this issue",
|
|
193
|
+
],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def add_pr_available_label(gh: str, number: int) -> None:
|
|
198
|
+
"""Label issue `number` `pr-available`. Caller must have already called
|
|
199
|
+
`ensure_pr_available_label` (once, not per-issue) so the label exists on the repo first.
|
|
200
|
+
"""
|
|
201
|
+
_run_gh(gh, ["issue", "edit", str(number), "--add-label", PR_AVAILABLE_LABEL])
|
|
202
|
+
|
|
203
|
+
|
|
169
204
|
def build_search_query(keywords: list[str], since: str | None) -> str:
|
|
170
205
|
"""The `gh issue list --search` query string: keywords ANDed together (GitHub search's
|
|
171
206
|
implicit default), optionally scoped to issues updated on/after `since` (an ISO
|
|
@@ -18,6 +18,7 @@ import tempfile
|
|
|
18
18
|
import time
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
|
+
from . import pr_issue_link
|
|
21
22
|
from .cli_tools import CLI_TIMEOUT_SECS, EXIT_NEEDS_APPROVAL, PollHeartbeat, detect_agent_session, ensure_agent_session_note, is_claude_code
|
|
22
23
|
from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section
|
|
23
24
|
|
|
@@ -490,6 +491,37 @@ def ensure_session_note(gh: str) -> None:
|
|
|
490
491
|
pass
|
|
491
492
|
|
|
492
493
|
|
|
494
|
+
def get_pr_body(gh: str) -> tuple[int, str]:
|
|
495
|
+
"""(PR number, PR body) for the current branch's PR -- used by issue-linking, which needs
|
|
496
|
+
the body actually stored (e.g. autofilled from the commit message via `--fill`), not
|
|
497
|
+
whatever a caller happened to pass to `pr create`.
|
|
498
|
+
"""
|
|
499
|
+
pr = _run_gh_json(gh, ["pr", "view", "--json", "number,body"])
|
|
500
|
+
return pr["number"], pr.get("body") or ""
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def link_issue_to_pr(gh: str, pr_number: int, body: str, issue_number: int) -> str:
|
|
504
|
+
"""Make sure `body` (the PR's current body) references `issue_number` via a GitHub closing
|
|
505
|
+
keyword, so GitHub shows this as a genuinely linked PR (appearing in the issue's
|
|
506
|
+
"Development" sidebar and auto-closing it on merge) -- a plain `#N` mention elsewhere (only
|
|
507
|
+
in a comment, or just prose referencing it, say) doesn't get that treatment, so it isn't
|
|
508
|
+
mistaken for one already being present (see `pr_issue_link.github_body_already_closes`). A
|
|
509
|
+
no-op if `body` already has a real closing-keyword reference to the issue (e.g. because this
|
|
510
|
+
number came from scanning the body for one in the first place).
|
|
511
|
+
|
|
512
|
+
Returns the (possibly unchanged) new body -- callers linking more than one issue in a row
|
|
513
|
+
pass this back in as `body` for the next call, so each edit builds on the last instead of
|
|
514
|
+
re-fetching or clobbering a previous append.
|
|
515
|
+
"""
|
|
516
|
+
if pr_issue_link.github_body_already_closes(body, issue_number):
|
|
517
|
+
return body
|
|
518
|
+
new_body = f"{body.rstrip()}\n\nFixes #{issue_number}\n".lstrip("\n")
|
|
519
|
+
r = _run([gh, "pr", "edit", str(pr_number), "--body", new_body], capture_output=True, encoding="utf-8")
|
|
520
|
+
if r.returncode != 0:
|
|
521
|
+
sys.exit((r.stderr or r.stdout).strip() or "`gh pr edit` failed")
|
|
522
|
+
return new_body
|
|
523
|
+
|
|
524
|
+
|
|
493
525
|
def publish(gh: str) -> None:
|
|
494
526
|
"""Mark the current branch's draft PR as ready for review."""
|
|
495
527
|
r = _run([gh, "pr", "ready"], capture_output=True, encoding="utf-8")
|
|
@@ -50,6 +50,12 @@ def current_branch(cwd: str | None = None) -> str:
|
|
|
50
50
|
return _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def head_commit_subject(cwd: str | None = None) -> str:
|
|
54
|
+
"""Subject line of the current branch's HEAD commit -- e.g. used to derive a PR's
|
|
55
|
+
conventional-commit scope (`type(scope): ...`) locally, before the PR exists."""
|
|
56
|
+
return _run_git(["log", "-1", "--format=%s"], cwd=cwd)
|
|
57
|
+
|
|
58
|
+
|
|
53
59
|
def origin_url(cwd: str | None = None) -> str:
|
|
54
60
|
return _run_git(["remote", "get-url", "origin"], cwd=cwd)
|
|
55
61
|
|
|
@@ -305,6 +305,21 @@ def update(
|
|
|
305
305
|
print(f"Updated PR #{pr_id}")
|
|
306
306
|
|
|
307
307
|
|
|
308
|
+
def link_work_item(session: requests.Session, remote: AdoRemote, pr_id: int, work_item_id: int) -> None:
|
|
309
|
+
"""Link work item `work_item_id` to PR `pr_id`, so it shows up in the PR's "Work Items" tab
|
|
310
|
+
(and the work item's own "Development" links) -- the Azure DevOps equivalent of GitHub's
|
|
311
|
+
closing-keyword PR<->issue link, via the dedicated Pull Request Work Items REST resource
|
|
312
|
+
(POST .../pullRequests/{pr_id}/workitems/{work_item_id}, the same one `az repos pr work-item
|
|
313
|
+
add` itself calls). Idempotent -- linking an already-linked work item just re-confirms it.
|
|
314
|
+
"""
|
|
315
|
+
r = session.post(
|
|
316
|
+
f"{_base_url(remote)}/_apis/git/repositories/{quote(remote.repo, safe='')}/pullRequests/{pr_id}/workitems/{work_item_id}",
|
|
317
|
+
params={"api-version": "7.1"},
|
|
318
|
+
timeout=HTTP_TIMEOUT_SECS,
|
|
319
|
+
)
|
|
320
|
+
r.raise_for_status()
|
|
321
|
+
|
|
322
|
+
|
|
308
323
|
def publish(session: requests.Session, remote: AdoRemote, pr: dict) -> None:
|
|
309
324
|
"""Mark a draft PR as ready for review (clears isDraft)."""
|
|
310
325
|
pr_id = pr["pullRequestId"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Parse `bdt pr create --issue` references and scan a PR body for issue/work-item mentions.
|
|
2
|
+
|
|
3
|
+
Both feed the same downstream treatment (see `cli.py::pr_create`): link the issue/work item to
|
|
4
|
+
the PR and label it `pr-available` (a GitHub label, or an Azure DevOps tag -- Azure DevOps has no
|
|
5
|
+
label concept of its own, tags are the closest equivalent, same as `ado_issue.py`'s `--tag`).
|
|
6
|
+
|
|
7
|
+
An issue number alone is only unique within a single tracker -- same reasoning `gh_issue.py`'s
|
|
8
|
+
`_extract_issue_numbers` uses for board membership -- so a URL is only accepted here when it
|
|
9
|
+
names *this* repo's own tracker (same GitHub owner/repo, or same Azure DevOps org). A URL for
|
|
10
|
+
some other repo/org is rejected/ignored rather than silently acted on.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from .gitrepo import AdoRemote, GitHubRemote
|
|
18
|
+
|
|
19
|
+
PR_AVAILABLE_LABEL = "pr-available"
|
|
20
|
+
|
|
21
|
+
# GitHub's own PR-body/commit-message closing keywords -- see
|
|
22
|
+
# https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue
|
|
23
|
+
_GITHUB_KEYWORD_RE = re.compile(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)", re.IGNORECASE)
|
|
24
|
+
|
|
25
|
+
# Matches a GitHub issue URL, e.g. https://github.com/owner/repo/issues/42. The `(?<![\w-])`
|
|
26
|
+
# lookbehind anchors "github.com" to an actual host boundary (start of string, `//`, or a `.`
|
|
27
|
+
# subdomain separator) -- without it, a URL on an unrelated domain that merely *contains* the
|
|
28
|
+
# substring "github.com" (e.g. https://notgithub.com/owner/repo/issues/42, or any host ending in
|
|
29
|
+
# "-github.com") would be misidentified as pointing at github.com itself.
|
|
30
|
+
_GITHUB_ISSUE_URL_RE = re.compile(r"(?<![\w-])github\.com/([^/\s]+)/([^/\s]+)/issues/(\d+)")
|
|
31
|
+
|
|
32
|
+
# Matches an Azure DevOps work item URL, e.g. https://dev.azure.com/org/project/_workitems/edit/42
|
|
33
|
+
# (also the older org.visualstudio.com host form). Same host-boundary reasoning as
|
|
34
|
+
# `_GITHUB_ISSUE_URL_RE` for the "dev.azure.com" branch; the "*.visualstudio.com" branch's
|
|
35
|
+
# `[^./\s]+` group already only captures a single dot-delimited label, so it can't be fooled the
|
|
36
|
+
# same way.
|
|
37
|
+
_ADO_WORK_ITEM_URL_RE = re.compile(r"(?:(?<![\w-])dev\.azure\.com/([^/\s]+)|([^./\s]+)\.visualstudio\.com)/\S*_workitems/edit/(\d+)")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_issue_ref(ref: str, remote: AdoRemote | GitHubRemote) -> int:
|
|
41
|
+
"""A bare issue/work-item number, or a URL naming *this* repo's own tracker, -> its number.
|
|
42
|
+
|
|
43
|
+
Raises ValueError (with a message suitable to surface via `typer.BadParameter`) for anything
|
|
44
|
+
else: an unparseable value, or a URL for a different repo/org than `remote`.
|
|
45
|
+
"""
|
|
46
|
+
ref = ref.strip()
|
|
47
|
+
if ref.isdigit():
|
|
48
|
+
return int(ref)
|
|
49
|
+
|
|
50
|
+
if isinstance(remote, GitHubRemote):
|
|
51
|
+
match = _GITHUB_ISSUE_URL_RE.search(ref)
|
|
52
|
+
if match and match.group(1).casefold() == remote.owner.casefold() and match.group(2).casefold() == remote.repo.casefold():
|
|
53
|
+
return int(match.group(3))
|
|
54
|
+
raise ValueError(f"'{ref}' isn't a bare issue number or a github.com/{remote.owner}/{remote.repo}/issues/<N> URL")
|
|
55
|
+
|
|
56
|
+
match = _ADO_WORK_ITEM_URL_RE.search(ref)
|
|
57
|
+
if match:
|
|
58
|
+
org = match.group(1) or match.group(2)
|
|
59
|
+
if org and org.casefold() == remote.org.casefold():
|
|
60
|
+
return int(match.group(3))
|
|
61
|
+
raise ValueError(f"'{ref}' isn't a bare work item number or a dev.azure.com/{remote.org}/.../_workitems/edit/<N> URL")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def github_body_already_closes(body: str, issue_number: int) -> bool:
|
|
65
|
+
"""True if `body` already contains a GitHub closing-keyword reference (`Fixes`/`Closes`/
|
|
66
|
+
`Resolves #N`, any tense/plural form) to `issue_number`.
|
|
67
|
+
|
|
68
|
+
Deliberately narrower than "the body mentions #N anywhere" -- a plain, non-keyword mention
|
|
69
|
+
(e.g. "see #42 for background") doesn't make GitHub treat the PR as genuinely linked to that
|
|
70
|
+
issue (no Development-sidebar entry, no auto-close on merge), so it must not be mistaken for
|
|
71
|
+
one already being present.
|
|
72
|
+
"""
|
|
73
|
+
return any(int(m) == issue_number for m in _GITHUB_KEYWORD_RE.findall(body))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def find_issue_refs_in_body(body: str | None, remote: AdoRemote | GitHubRemote) -> list[int]:
|
|
77
|
+
"""Issue/work-item numbers mentioned in `body` that belong to *this* repo's own tracker, in
|
|
78
|
+
first-seen order.
|
|
79
|
+
|
|
80
|
+
GitHub: `Fixes #N`/`Closes #N`/`Resolves #N` (and their tense/plural variants) plus full
|
|
81
|
+
issue URLs for this owner/repo. Azure DevOps: full work-item URLs for this org only -- Azure
|
|
82
|
+
DevOps has no bare-`#N` keyword syntax of its own for this (unlike GitHub's, `AB#N` is a
|
|
83
|
+
commit-message-only linking convention, not something `pr create`'s body scan is asked to
|
|
84
|
+
understand here).
|
|
85
|
+
"""
|
|
86
|
+
if not body:
|
|
87
|
+
return []
|
|
88
|
+
numbers: list[int] = []
|
|
89
|
+
seen: set[int] = set()
|
|
90
|
+
|
|
91
|
+
def _add(n: int) -> None:
|
|
92
|
+
if n not in seen:
|
|
93
|
+
seen.add(n)
|
|
94
|
+
numbers.append(n)
|
|
95
|
+
|
|
96
|
+
if isinstance(remote, GitHubRemote):
|
|
97
|
+
for match in _GITHUB_KEYWORD_RE.finditer(body):
|
|
98
|
+
_add(int(match.group(1)))
|
|
99
|
+
for match in _GITHUB_ISSUE_URL_RE.finditer(body):
|
|
100
|
+
if match.group(1).casefold() == remote.owner.casefold() and match.group(2).casefold() == remote.repo.casefold():
|
|
101
|
+
_add(int(match.group(3)))
|
|
102
|
+
else:
|
|
103
|
+
for match in _ADO_WORK_ITEM_URL_RE.finditer(body):
|
|
104
|
+
org = match.group(1) or match.group(2)
|
|
105
|
+
if org and org.casefold() == remote.org.casefold():
|
|
106
|
+
_add(int(match.group(3)))
|
|
107
|
+
|
|
108
|
+
return numbers
|
|
@@ -10,6 +10,20 @@ create` must be given at least one label from every configured group
|
|
|
10
10
|
(checked locally, before talking to GitHub/Azure DevOps, so a PR missing a
|
|
11
11
|
required label is never created in the first place). Applies identically on
|
|
12
12
|
both hosts.
|
|
13
|
+
|
|
14
|
+
Also: scope -> label auto-labeling, configured under `[tool.bdt.pr.scope_labels]`:
|
|
15
|
+
|
|
16
|
+
[tool.bdt.pr.scope_labels]
|
|
17
|
+
customers = "e2e-customers"
|
|
18
|
+
billing = "e2e-billing"
|
|
19
|
+
|
|
20
|
+
`bdt pr create` derives the "scope" from the HEAD commit's conventional-commit
|
|
21
|
+
subject using the same parser `bdt commit` itself enforces
|
|
22
|
+
(`commit.conventional_commit_scope`, e.g. `feat(customers): ...` -> scope
|
|
23
|
+
"customers"). If the derived scope has a configured label, that label is
|
|
24
|
+
added to the PR's `--label` list automatically (deduplicated against any
|
|
25
|
+
explicitly-passed `--label`). Opt-in: with no `[tool.bdt.pr.scope_labels]`
|
|
26
|
+
table, this is a no-op and behavior is unchanged.
|
|
13
27
|
"""
|
|
14
28
|
|
|
15
29
|
from __future__ import annotations
|
|
@@ -27,6 +41,24 @@ def required_label_groups(start: Path | None = None) -> dict[str, list[str]]:
|
|
|
27
41
|
return {name: choices for name, choices in raw.items() if isinstance(choices, list)}
|
|
28
42
|
|
|
29
43
|
|
|
44
|
+
def scope_labels(start: Path | None = None) -> dict[str, str]:
|
|
45
|
+
"""`[tool.bdt.pr.scope_labels]` -> {scope: label to auto-apply}."""
|
|
46
|
+
raw = load_bdt_table("pr", start).get("scope_labels", {})
|
|
47
|
+
if not isinstance(raw, dict):
|
|
48
|
+
return {}
|
|
49
|
+
return {name: label for name, label in raw.items() if isinstance(label, str)}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def label_for_scope(scope_label_map: dict[str, str], scope: str | None) -> str | None:
|
|
53
|
+
"""The configured label for `scope`, matched case-insensitively (same
|
|
54
|
+
convention as `missing_label_groups`), or None if `scope` is unset or
|
|
55
|
+
unconfigured."""
|
|
56
|
+
if not scope:
|
|
57
|
+
return None
|
|
58
|
+
lowered = {name.lower(): label for name, label in scope_label_map.items()}
|
|
59
|
+
return lowered.get(scope.lower())
|
|
60
|
+
|
|
61
|
+
|
|
30
62
|
def missing_label_groups(groups: dict[str, list[str]], labels: list[str]) -> dict[str, list[str]]:
|
|
31
63
|
"""The subset of `groups` for which none of `labels` is a member.
|
|
32
64
|
|