bmsdna-devtools 0.22.0__tar.gz → 0.24.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.24.0/PKG-INFO +28 -0
- bmsdna_devtools-0.22.0/PKG-INFO → bmsdna_devtools-0.24.0/README.md +17 -11
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/ado_issue.py +101 -21
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/cli.py +130 -5
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/gh_issue.py +86 -3
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/gh_pr.py +32 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/gitrepo.py +6 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_build.py +15 -0
- bmsdna_devtools-0.24.0/bmsdna/devtools/pr_issue_link.py +108 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_labels.py +32 -0
- bmsdna_devtools-0.24.0/bmsdna/devtools/pull.py +286 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/worktree.py +21 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_ado_issue.py +136 -0
- bmsdna_devtools-0.24.0/tests/test_cli_pr_create.py +569 -0
- bmsdna_devtools-0.24.0/tests/test_cli_pull.py +45 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_gh_issue.py +136 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_gh_pr.py +82 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_gitrepo.py +19 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_pr_build.py +27 -0
- bmsdna_devtools-0.24.0/tests/test_pr_issue_link.py +158 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_pr_labels.py +52 -1
- bmsdna_devtools-0.24.0/tests/test_pull.py +390 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_worktree_clean.py +64 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.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.24.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/.gitignore +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/.python-version +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/justfile +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_testdb.py +0 -0
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bmsdna-devtools
|
|
3
|
+
Version: 0.24.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"
|
|
@@ -59,8 +60,12 @@ def resolve_board(board: str | None, start: Path | None = None) -> str | None:
|
|
|
59
60
|
return board or load_bdt_table("ado", start).get("board")
|
|
60
61
|
|
|
61
62
|
|
|
63
|
+
def _org_url(remote: AdoRemote) -> str:
|
|
64
|
+
return f"https://dev.azure.com/{quote(remote.org, safe='')}"
|
|
65
|
+
|
|
66
|
+
|
|
62
67
|
def _base_url(remote: AdoRemote) -> str:
|
|
63
|
-
return f"
|
|
68
|
+
return f"{_org_url(remote)}/{quote(remote.project, safe='')}"
|
|
64
69
|
|
|
65
70
|
|
|
66
71
|
def get_team_area_path(session: requests.Session, remote: AdoRemote, team: str) -> str:
|
|
@@ -204,6 +209,39 @@ def get_valid_states(session: requests.Session, remote: AdoRemote, work_item_typ
|
|
|
204
209
|
return [s["name"] for s in r.json()["value"]]
|
|
205
210
|
|
|
206
211
|
|
|
212
|
+
def get_work_item_tags(session: requests.Session, remote: AdoRemote, work_item_id: int) -> list[str]:
|
|
213
|
+
r = session.get(
|
|
214
|
+
f"{_base_url(remote)}/_apis/wit/workitems/{work_item_id}",
|
|
215
|
+
params={"fields": "System.Tags", "api-version": "7.1"},
|
|
216
|
+
)
|
|
217
|
+
if r.status_code == 404:
|
|
218
|
+
sys.exit(f"Work item #{work_item_id} not found in project '{remote.project}'.")
|
|
219
|
+
r.raise_for_status()
|
|
220
|
+
raw = r.json().get("fields", {}).get("System.Tags") or ""
|
|
221
|
+
return [t.strip() for t in raw.split(";") if t.strip()]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def add_tag(session: requests.Session, remote: AdoRemote, work_item_id: int, tag: str) -> None:
|
|
225
|
+
"""Add `tag` to work item `work_item_id`'s tags, alongside whatever's already there.
|
|
226
|
+
|
|
227
|
+
`update_work_item`'s tags op replaces the whole `System.Tags` field (there's no
|
|
228
|
+
Azure DevOps "add one tag" patch op), so this reads the current tags first and only
|
|
229
|
+
PATCHes if `tag` isn't already among them -- otherwise a second `pr create --issue`
|
|
230
|
+
against the same work item would keep re-sending an unchanged value.
|
|
231
|
+
"""
|
|
232
|
+
tags = get_work_item_tags(session, remote, work_item_id)
|
|
233
|
+
if any(t.casefold() == tag.casefold() for t in tags):
|
|
234
|
+
return
|
|
235
|
+
update_work_item(session, remote, work_item_id, tags=[*tags, tag])
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def add_pr_available_tag(session: requests.Session, remote: AdoRemote, work_item_id: int) -> None:
|
|
239
|
+
"""Tag work item `work_item_id` `pr-available` -- the Azure DevOps equivalent of the
|
|
240
|
+
GitHub `pr-available` label (Azure DevOps work items have no label concept; tags are the
|
|
241
|
+
closest equivalent, same as `ado_issue.py`'s own `--tag` create/update flag)."""
|
|
242
|
+
add_tag(session, remote, work_item_id, PR_AVAILABLE_LABEL)
|
|
243
|
+
|
|
244
|
+
|
|
207
245
|
def delete_work_item(session: requests.Session, remote: AdoRemote, work_item_id: int) -> None:
|
|
208
246
|
"""Soft-delete: moves the work item to the project's Recycle Bin, where it can be restored.
|
|
209
247
|
|
|
@@ -361,8 +399,11 @@ def html_url(work_item: dict) -> str | None:
|
|
|
361
399
|
return work_item.get("_links", {}).get("html", {}).get("href")
|
|
362
400
|
|
|
363
401
|
|
|
364
|
-
def edit_url(remote: AdoRemote, work_item_id: int) -> str:
|
|
365
|
-
|
|
402
|
+
def edit_url(remote: AdoRemote, work_item_id: int, project: str | None = None) -> str:
|
|
403
|
+
"""`project` overrides `remote.project` -- needed by an org-wide `search()`, whose results
|
|
404
|
+
can belong to a different project than the one the command was run in.
|
|
405
|
+
"""
|
|
406
|
+
return f"{_org_url(remote)}/{quote(project or remote.project, safe='')}/_workitems/edit/{work_item_id}"
|
|
366
407
|
|
|
367
408
|
|
|
368
409
|
def _escape_wiql_string(value: str) -> str:
|
|
@@ -381,11 +422,13 @@ def build_search_wiql(
|
|
|
381
422
|
since: str | None = None,
|
|
382
423
|
area_path: str | None = None,
|
|
383
424
|
state: str = "open",
|
|
425
|
+
org_wide: bool = False,
|
|
426
|
+
tags: list[str] | None = None,
|
|
384
427
|
) -> str:
|
|
385
428
|
"""WIQL for work items whose Title or Description contains every given keyword (ANDed),
|
|
386
|
-
optionally restricted to items changed on/after `since` (an ISO 'YYYY-MM-DD' date)
|
|
387
|
-
scoped to a board's Area Path subtree, most recently changed
|
|
388
|
-
to list work items without a text filter.
|
|
429
|
+
optionally restricted to items changed on/after `since` (an ISO 'YYYY-MM-DD' date), carrying
|
|
430
|
+
every given tag (ANDed), and/or scoped to a board's Area Path subtree, most recently changed
|
|
431
|
+
first. `keywords` may be empty, to list work items without a text filter.
|
|
389
432
|
|
|
390
433
|
`since` is rendered as a UTC ISO 8601 literal (`'YYYY-MM-DDT00:00:00Z'`) — the one
|
|
391
434
|
DateTime format WIQL accepts regardless of the querying account's locale/date-pattern
|
|
@@ -396,11 +439,21 @@ def build_search_wiql(
|
|
|
396
439
|
CMMI use 'Closed' for their Completed-category state, Scrum and Basic use 'Done' — so
|
|
397
440
|
`_TERMINAL_STATES` is the union across the built-in templates, not a per-project source of
|
|
398
441
|
truth (a custom process with its own state names won't be filtered correctly).
|
|
442
|
+
|
|
443
|
+
`org_wide` drops the `[System.TeamProject] = @project` clause -- `@project` is only valid
|
|
444
|
+
when the query runs against a project-scoped WIQL endpoint (see `run_wiql`'s `org_wide`),
|
|
445
|
+
and dropping it is what actually lets the query match work items outside the current project.
|
|
446
|
+
|
|
447
|
+
`tags` is a separate filter from `keywords` -- `Contains Words` against Title/Description
|
|
448
|
+
doesn't match `System.Tags` at all, so a tag search (e.g. every work item tagged
|
|
449
|
+
'ready4implementation' across the whole org) needs its own `[System.Tags] Contains` clause.
|
|
399
450
|
"""
|
|
400
451
|
clauses = [
|
|
401
452
|
f"([System.Title] Contains Words '{_escape_wiql_string(k)}' OR [System.Description] Contains Words '{_escape_wiql_string(k)}')"
|
|
402
453
|
for k in keywords
|
|
403
454
|
]
|
|
455
|
+
for tag in tags or []:
|
|
456
|
+
clauses.append(f"[System.Tags] Contains '{_escape_wiql_string(tag)}'")
|
|
404
457
|
if since:
|
|
405
458
|
clauses.append(f"[System.ChangedDate] >= '{since}T00:00:00Z'")
|
|
406
459
|
if area_path:
|
|
@@ -409,13 +462,21 @@ def build_search_wiql(
|
|
|
409
462
|
clauses.append(" AND ".join(f"[System.State] <> '{s}'" for s in _TERMINAL_STATES))
|
|
410
463
|
elif state == "closed":
|
|
411
464
|
clauses.append("(" + " OR ".join(f"[System.State] = '{s}'" for s in _TERMINAL_STATES) + ")")
|
|
412
|
-
|
|
413
|
-
|
|
465
|
+
if not org_wide:
|
|
466
|
+
clauses.insert(0, "[System.TeamProject] = @project")
|
|
467
|
+
query = "SELECT [System.Id] FROM WorkItems"
|
|
468
|
+
if clauses:
|
|
469
|
+
query += " WHERE " + " AND ".join(clauses)
|
|
470
|
+
return f"{query} ORDER BY [System.ChangedDate] DESC"
|
|
414
471
|
|
|
415
472
|
|
|
416
|
-
def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int) -> list[int]:
|
|
473
|
+
def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int, org_wide: bool = False) -> list[int]:
|
|
474
|
+
"""`org_wide` runs the query against the org-level WIQL endpoint (no `/{project}` segment),
|
|
475
|
+
which Azure DevOps resolves across every project in the org instead of just `remote.project`.
|
|
476
|
+
"""
|
|
477
|
+
base = _org_url(remote) if org_wide else _base_url(remote)
|
|
417
478
|
r = session.post(
|
|
418
|
-
f"{
|
|
479
|
+
f"{base}/_apis/wit/wiql",
|
|
419
480
|
params={"api-version": "7.1", "$top": top},
|
|
420
481
|
json={"query": wiql},
|
|
421
482
|
)
|
|
@@ -423,16 +484,23 @@ def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int)
|
|
|
423
484
|
return [wi["id"] for wi in r.json()["workItems"]]
|
|
424
485
|
|
|
425
486
|
|
|
426
|
-
def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int]) -> list[dict]:
|
|
427
|
-
"""Batch-fetch Title/State for a set of work item ids, in
|
|
428
|
-
returns ids (not field values), and this batch endpoint
|
|
429
|
-
in the order they were requested, so the caller's WIQL
|
|
487
|
+
def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int], org_wide: bool = False) -> list[dict]:
|
|
488
|
+
"""Batch-fetch Title/State (plus TeamProject when `org_wide`) for a set of work item ids, in
|
|
489
|
+
the given `ids` order — WIQL only returns ids (not field values), and this batch endpoint
|
|
490
|
+
doesn't guarantee it echoes them back in the order they were requested, so the caller's WIQL
|
|
491
|
+
`ORDER BY` isn't preserved otherwise.
|
|
492
|
+
|
|
493
|
+
`org_wide` fetches via the org-level endpoint (ids from an org-wide `run_wiql` may belong to
|
|
494
|
+
a project other than `remote.project`) and also asks for `System.TeamProject`, so `search()`
|
|
495
|
+
can show which project each result came from.
|
|
430
496
|
"""
|
|
431
497
|
if not ids:
|
|
432
498
|
return []
|
|
499
|
+
base = _org_url(remote) if org_wide else _base_url(remote)
|
|
500
|
+
fields = "System.Title,System.State,System.TeamProject" if org_wide else "System.Title,System.State"
|
|
433
501
|
r = session.get(
|
|
434
|
-
f"{
|
|
435
|
-
params={"ids": ",".join(map(str, ids)), "fields":
|
|
502
|
+
f"{base}/_apis/wit/workitems",
|
|
503
|
+
params={"ids": ",".join(map(str, ids)), "fields": fields, "api-version": "7.1"},
|
|
436
504
|
)
|
|
437
505
|
r.raise_for_status()
|
|
438
506
|
by_id = {item["id"]: item for item in r.json()["value"]}
|
|
@@ -447,19 +515,31 @@ def search(
|
|
|
447
515
|
board: str | None = None,
|
|
448
516
|
top: int = 10,
|
|
449
517
|
state: str = "open",
|
|
518
|
+
org_wide: bool = False,
|
|
519
|
+
tags: list[str] | None = None,
|
|
450
520
|
) -> list[dict]:
|
|
451
521
|
"""Search (or, with no keywords, just list) work items by keywords (ANDed, matched against
|
|
452
522
|
Title or Description) and state, most recently changed first. `board` is an Azure Boards team
|
|
453
523
|
name (like `create`'s `--board`) — resolved to its Area Path so results are scoped to that
|
|
454
|
-
team's subtree instead of the whole project.
|
|
524
|
+
team's subtree instead of the whole project. `tags` is a separate ANDed filter against
|
|
525
|
+
`System.Tags` (see `build_search_wiql`) -- a plain keyword doesn't match tags.
|
|
526
|
+
|
|
527
|
+
`org_wide` searches every project in `remote.org` instead of just `remote.project` --
|
|
528
|
+
mutually exclusive with `board`, since a team's Area Path only means something within a
|
|
529
|
+
single project.
|
|
455
530
|
"""
|
|
531
|
+
if org_wide and board:
|
|
532
|
+
sys.exit("--board can't be combined with --org-wide (a board's Area Path is scoped to a single project).")
|
|
456
533
|
area_path = get_team_area_path(session, remote, board) if board else None
|
|
457
|
-
|
|
458
|
-
|
|
534
|
+
wiql = build_search_wiql(keywords, since, area_path, state, org_wide=org_wide, tags=tags)
|
|
535
|
+
ids = run_wiql(session, remote, wiql, top, org_wide=org_wide)
|
|
536
|
+
items = get_work_items(session, remote, ids, org_wide=org_wide)
|
|
459
537
|
for item in items:
|
|
460
538
|
fields = item["fields"]
|
|
461
|
-
|
|
462
|
-
|
|
539
|
+
project = fields.get("System.TeamProject")
|
|
540
|
+
prefix = f"[{project}] " if org_wide else ""
|
|
541
|
+
print(f"{prefix}#{item['id']} [{fields['System.State']}] {fields['System.Title']}")
|
|
542
|
+
print(edit_url(remote, item["id"], project if org_wide else None))
|
|
463
543
|
if not items:
|
|
464
544
|
print("No matching work items found.")
|
|
465
545
|
return items
|
|
@@ -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}")
|
|
@@ -450,6 +537,14 @@ def issue_search(
|
|
|
450
537
|
r"(falls back to \[tool.bdt.github].board), same as `issue create`",
|
|
451
538
|
),
|
|
452
539
|
limit: int = typer.Option(10, "--limit", help="Max results to return"),
|
|
540
|
+
org_wide: bool = typer.Option(
|
|
541
|
+
False,
|
|
542
|
+
"--org-wide",
|
|
543
|
+
help="Search every project in the Azure DevOps org, or every repo owned by the GitHub owner, "
|
|
544
|
+
"instead of just the current one. Can't be combined with --board.",
|
|
545
|
+
),
|
|
546
|
+
label: list[str] = typer.Option([], "--label", help="Only issues carrying this label, ANDed (GitHub only, repeatable)"),
|
|
547
|
+
tag: list[str] = typer.Option([], "--tag", help="Only work items carrying this tag, ANDed (Azure DevOps only, repeatable)"),
|
|
453
548
|
pat: str | None = typer.Option(
|
|
454
549
|
None,
|
|
455
550
|
"--pat",
|
|
@@ -467,12 +562,14 @@ def issue_search(
|
|
|
467
562
|
remote = current_remote()
|
|
468
563
|
if isinstance(remote, GitHubRemote):
|
|
469
564
|
resolved_board = gh_issue.resolve_board(board)
|
|
470
|
-
gh_issue.search(
|
|
565
|
+
gh_issue.search(
|
|
566
|
+
require_gh(), remote.owner, remote.repo, keywords or [], since, limit, state, board=resolved_board, org_wide=org_wide, labels=label
|
|
567
|
+
)
|
|
471
568
|
else:
|
|
472
569
|
session = requests.Session()
|
|
473
570
|
session.headers.update(auth_header(pat))
|
|
474
571
|
resolved_board = ado_issue.resolve_board(board)
|
|
475
|
-
ado_issue.search(session, remote, keywords or [], since=since, board=resolved_board, top=limit, state=state)
|
|
572
|
+
ado_issue.search(session, remote, keywords or [], since=since, board=resolved_board, top=limit, state=state, org_wide=org_wide, tags=tag)
|
|
476
573
|
|
|
477
574
|
|
|
478
575
|
@issue_app.command("update")
|
|
@@ -639,6 +736,34 @@ def worktree(
|
|
|
639
736
|
worktree_mod.create(name, base=base, env_file=env_file, submodules=submodules, install_cmd=install_cmd)
|
|
640
737
|
|
|
641
738
|
|
|
739
|
+
@app.command()
|
|
740
|
+
def pull(
|
|
741
|
+
remote: str = typer.Option(
|
|
742
|
+
"origin",
|
|
743
|
+
"--remote",
|
|
744
|
+
help="Remote to pull main/master and the default branch from (the current branch's own tracking "
|
|
745
|
+
"branch step always follows its real configured upstream, regardless of this)",
|
|
746
|
+
),
|
|
747
|
+
no_default: bool = typer.Option(
|
|
748
|
+
False,
|
|
749
|
+
"--no-default",
|
|
750
|
+
help="Don't also pull the repo's DEFAULT branch -- skip this when the current branch was already "
|
|
751
|
+
"branched from main/master, since pulling it again would be redundant",
|
|
752
|
+
),
|
|
753
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print what would be pulled, without doing it"),
|
|
754
|
+
pull_args: list[str] = typer.Argument(
|
|
755
|
+
None,
|
|
756
|
+
help="Extra flags passed through to every `git pull` step, e.g. `-- --rebase --ff-only` (put them after `--`)",
|
|
757
|
+
),
|
|
758
|
+
) -> None:
|
|
759
|
+
"""Bring the current branch up to date from three sources: its own remote tracking branch, the
|
|
760
|
+
remote's main/master branch, and the repo's DEFAULT branch (unless --no-default) -- each a
|
|
761
|
+
separate `git pull`, stopping with a clear error (and the exact command to re-run) if any step
|
|
762
|
+
hits a merge conflict.
|
|
763
|
+
"""
|
|
764
|
+
pull_mod.run(remote=remote, no_default=no_default, dry_run=dry_run, pull_args=pull_args or [])
|
|
765
|
+
|
|
766
|
+
|
|
642
767
|
@app.command("find-repo")
|
|
643
768
|
def find_repo_cmd(
|
|
644
769
|
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
|
|
@@ -180,18 +215,66 @@ def build_search_query(keywords: list[str], since: str | None) -> str:
|
|
|
180
215
|
|
|
181
216
|
|
|
182
217
|
def search(
|
|
183
|
-
gh: str,
|
|
218
|
+
gh: str,
|
|
219
|
+
owner: str,
|
|
220
|
+
repo: str,
|
|
221
|
+
keywords: list[str],
|
|
222
|
+
since: str | None,
|
|
223
|
+
limit: int,
|
|
224
|
+
state: str = "open",
|
|
225
|
+
board: str | None = None,
|
|
226
|
+
org_wide: bool = False,
|
|
227
|
+
labels: list[str] | None = None,
|
|
184
228
|
) -> list[dict]:
|
|
185
229
|
"""Search (or, with no keywords, just list) issues by state, most recently updated first.
|
|
186
230
|
|
|
187
231
|
`state` is `gh issue list`'s own `open|closed|all` flag, not a search qualifier. `board`
|
|
188
232
|
scopes results to a GitHub Projects (v2) board (by title or number) -- `gh issue list` has no
|
|
189
233
|
board filter of its own, so this over-fetches a wider raw pool first and filters down to
|
|
190
|
-
`limit` afterward, client-side.
|
|
234
|
+
`limit` afterward, client-side. `labels` is ANDed (an issue must carry every given label) --
|
|
235
|
+
both `gh issue list` and `gh search issues` support a native, repeatable `--label` flag, so
|
|
236
|
+
it doesn't need folding into `query` as a `label:` qualifier.
|
|
237
|
+
|
|
238
|
+
`org_wide` searches every repo `owner` has, via `gh search issues --owner` (GitHub's
|
|
239
|
+
cross-repo search endpoint) instead of `gh issue list` (always single-repo, scoped by cwd's
|
|
240
|
+
git remote) -- mutually exclusive with `board`, whose membership is only resolved against
|
|
241
|
+
`repo`, not every repo under `owner`.
|
|
191
242
|
"""
|
|
243
|
+
if org_wide and board:
|
|
244
|
+
sys.exit("--board can't be combined with --org-wide (board membership is only resolved for the current repo).")
|
|
245
|
+
|
|
192
246
|
query = build_search_query(keywords, since)
|
|
247
|
+
label_args = [arg for label in labels or [] for arg in ("--label", label)]
|
|
248
|
+
|
|
249
|
+
if org_wide:
|
|
250
|
+
# `gh search issues --state` only accepts open/closed (unlike `gh issue list --state`
|
|
251
|
+
# below, which also takes 'all') -- so 'all' is passed by omitting --state entirely
|
|
252
|
+
# (`gh search issues`'s own default), rather than forwarding a value it would reject.
|
|
253
|
+
#
|
|
254
|
+
# `gh search issues`' `--json state` also returns lowercase 'open'/'closed' (the GitHub
|
|
255
|
+
# search API's own casing), unlike `gh issue list`'s 'OPEN'/'CLOSED' below (GraphQL enum
|
|
256
|
+
# casing) -- both are printed as-is, so the two search modes' output differs in casing.
|
|
257
|
+
state_args = [] if state == "all" else ["--state", state]
|
|
258
|
+
out = _run_gh(
|
|
259
|
+
gh,
|
|
260
|
+
[
|
|
261
|
+
"search", "issues", query, "--owner", owner, *state_args, *label_args,
|
|
262
|
+
"--limit", str(limit), "--json", "number,title,url,state,repository",
|
|
263
|
+
],
|
|
264
|
+
)
|
|
265
|
+
items = json.loads(out) if out else []
|
|
266
|
+
for item in items:
|
|
267
|
+
print(f"{item['repository']['nameWithOwner']}#{item['number']} [{item['state']}] {item['title']}")
|
|
268
|
+
print(item["url"])
|
|
269
|
+
if not items:
|
|
270
|
+
print("No matching issues found.")
|
|
271
|
+
return items
|
|
272
|
+
|
|
193
273
|
fetch_limit = limit * _BOARD_SEARCH_OVERFETCH if board else limit
|
|
194
|
-
out = _run_gh(
|
|
274
|
+
out = _run_gh(
|
|
275
|
+
gh,
|
|
276
|
+
["issue", "list", "--search", query, "--state", state, *label_args, "--limit", str(fetch_limit), "--json", "number,title,url,state"],
|
|
277
|
+
)
|
|
195
278
|
items = json.loads(out) if out else []
|
|
196
279
|
|
|
197
280
|
if board:
|
|
@@ -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")
|