bmsdna-devtools 0.2.2__tar.gz → 0.3.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.
Files changed (31) hide show
  1. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/PKG-INFO +3 -3
  2. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/README.md +1 -1
  3. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/cli.py +115 -4
  4. bmsdna_devtools-0.3.0/bmsdna/devtools/gh_pr.py +290 -0
  5. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/pr_build.py +92 -4
  6. bmsdna_devtools-0.3.0/bmsdna/devtools/pr_markdown.py +9 -0
  7. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/pyproject.toml +1 -1
  8. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/skills/bmsdna-devtools/SKILL.md +1 -1
  9. bmsdna_devtools-0.3.0/tests/test_pr_markdown.py +13 -0
  10. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/uv.lock +1 -1
  11. bmsdna_devtools-0.2.2/bmsdna/devtools/gh_pr.py +0 -168
  12. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/.github/workflows/python-publish.yml +0 -0
  13. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/.github/workflows/python-test.yml +0 -0
  14. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/.gitignore +0 -0
  15. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/.python-version +0 -0
  16. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/__init__.py +0 -0
  17. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/ado_auth.py +0 -0
  18. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/app_service_logs.py +0 -0
  19. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/cli_tools.py +0 -0
  20. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/commit.py +0 -0
  21. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/env_config.py +0 -0
  22. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/gitrepo.py +0 -0
  23. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/logs.py +0 -0
  24. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/worktree.py +0 -0
  25. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/justfile +0 -0
  26. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/tests/test_app_service_logs.py +0 -0
  27. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/tests/test_commit.py +0 -0
  28. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/tests/test_env_config.py +0 -0
  29. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/tests/test_gh_pr.py +0 -0
  30. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/tests/test_gitrepo.py +0 -0
  31. {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/tests/test_pr_build.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: bmsdna-devtools
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests>=2.32.0
@@ -53,7 +53,7 @@ Exit code 1 either way.
53
53
  **Azure DevOps**: org/project/repo are auto-detected from
54
54
  `git remote get-url origin` (handles SSH, `dev.azure.com` HTTPS, and
55
55
  `*.visualstudio.com` HTTPS forms). Auth is an explicit PAT (`--pat` or
56
- `AZURE_DEVOPS_PAT` env var), falling back to a short-lived token from the
56
+ `AZURE_DEVOPS_EXT_PAT`/`AZURE_DEVOPS_PAT` env var), falling back to a short-lived token from the
57
57
  caller's own `az login` — never embed a PAT literal in a script or CI file.
58
58
  `--target-branch` selects which PR to look at (ADO's search API needs one).
59
59
 
@@ -43,7 +43,7 @@ Exit code 1 either way.
43
43
  **Azure DevOps**: org/project/repo are auto-detected from
44
44
  `git remote get-url origin` (handles SSH, `dev.azure.com` HTTPS, and
45
45
  `*.visualstudio.com` HTTPS forms). Auth is an explicit PAT (`--pat` or
46
- `AZURE_DEVOPS_PAT` env var), falling back to a short-lived token from the
46
+ `AZURE_DEVOPS_EXT_PAT`/`AZURE_DEVOPS_PAT` env var), falling back to a short-lived token from the
47
47
  caller's own `az login` — never embed a PAT literal in a script or CI file.
48
48
  `--target-branch` selects which PR to look at (ADO's search API needs one).
49
49
 
@@ -2,8 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  import subprocess
4
4
  import sys
5
+ from collections.abc import Callable
5
6
  from pathlib import Path
6
7
 
8
+ import requests
7
9
  import typer
8
10
 
9
11
  from . import app_service_logs, commit as commit_mod
@@ -11,8 +13,9 @@ from . import env_config
11
13
  from . import gh_pr
12
14
  from . import logs as logs_mod
13
15
  from . import pr_build, worktree as worktree_mod
16
+ from .ado_auth import auth_header
14
17
  from .cli_tools import require_az, require_gh
15
- from .gitrepo import GitHubRemote, current_branch, current_remote
18
+ from .gitrepo import AdoRemote, GitHubRemote, current_branch, current_remote
16
19
 
17
20
  # Non-ASCII output (checkmarks, en-dashes in ADO project names, etc.) needs a
18
21
  # UTF-8 stream — the default Windows console codepage isn't UTF-8, and would
@@ -30,28 +33,73 @@ logs_app = typer.Typer(name="logs", help="Application Insights / Log Analytics q
30
33
  app.add_typer(logs_app, name="logs")
31
34
 
32
35
 
36
+ def _resolve_ado_pr(pat: str | None, remote: AdoRemote, source_branch: str, target: str) -> tuple[requests.Session, dict]:
37
+ session = requests.Session()
38
+ session.headers.update(auth_header(pat))
39
+ pr = pr_build.get_pr(session, remote, source_branch, target)
40
+ return session, pr
41
+
42
+
43
+ def _attach_screenshots(attach: Callable[[], None]) -> None:
44
+ """Run an attach-screenshots step without letting its failure mask an already-successful `pr create`.
45
+
46
+ The PR itself is already live by the time this runs; a transient failure
47
+ here (a rejected push, an attachment upload error, a stale --target not
48
+ matching the PR ADO actually created) should surface as a warning, not
49
+ flip the whole command's exit code or hide the fact that the PR exists.
50
+ """
51
+ try:
52
+ attach()
53
+ except (Exception, SystemExit) as e:
54
+ print(f"Warning: PR created, but attaching screenshots failed: {e}")
55
+
56
+
33
57
  @pr_app.command("create")
34
58
  def pr_create(
35
59
  target: str = typer.Option("main", "--target", help="Target branch (e.g. main, test)"),
60
+ screenshot: list[str] = typer.Option(
61
+ [], "--screenshot", help="Path to an image to attach to the PR description (repeatable)"
62
+ ),
63
+ pat: str | None = typer.Option(
64
+ None,
65
+ "--pat",
66
+ envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"],
67
+ help="Azure DevOps PAT (else falls back to `az` login)",
68
+ ),
36
69
  args: list[str] = typer.Argument(None, help="Extra args passed through to `az repos pr create` / `gh pr create`"),
37
70
  ) -> None:
38
71
  """Create a PR from the current branch into --target (Azure DevOps or GitHub, auto-detected)."""
72
+ for path in screenshot:
73
+ if not Path(path).is_file():
74
+ raise typer.BadParameter(f"Screenshot not found: {path}", param_hint="--screenshot")
75
+
39
76
  remote = current_remote()
77
+ source_branch = current_branch()
40
78
  if isinstance(remote, GitHubRemote):
41
79
  gh = require_gh()
42
80
  returncode = gh_pr.create(gh, target, args or [])
43
81
  build_policy = gh_pr.has_build_policy(gh, target)
82
+ if returncode == 0 and screenshot:
83
+ _attach_screenshots(lambda: gh_pr.add_screenshots(gh, remote.owner, remote.repo, source_branch, screenshot))
44
84
  else:
45
85
  az = require_az()
46
86
  cmd = [
47
87
  az, "repos", "pr", "create",
48
88
  "--target-branch", target,
49
- "--source-branch", current_branch(),
89
+ "--source-branch", source_branch,
50
90
  "--auto-complete", "false",
51
91
  *(args or []),
52
92
  ]
53
93
  returncode = subprocess.run(cmd).returncode
54
- build_policy = pr_build.has_build_policy(remote, target)
94
+ session = requests.Session()
95
+ session.headers.update(auth_header(pat))
96
+ build_policy = pr_build.has_build_policy(session, remote, target)
97
+ if returncode == 0 and screenshot:
98
+ def _add() -> None:
99
+ pr = pr_build.get_pr(session, remote, source_branch, target)
100
+ pr_build.add_screenshots(session, remote, pr, screenshot)
101
+
102
+ _attach_screenshots(_add)
55
103
 
56
104
  if returncode == 0 and build_policy:
57
105
  print("\nRun `bdt pr status` to check whether the CI build passes.")
@@ -63,7 +111,7 @@ def pr_create(
63
111
  def pr_status(
64
112
  target_branch: str = typer.Option("main", "--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)"),
65
113
  wait: bool = typer.Option(False, "--wait", help="Poll until all pipelines/checks are completed"),
66
- pat: str | None = typer.Option(None, "--pat", envvar="AZURE_DEVOPS_PAT", help="Azure DevOps PAT (else falls back to `az` login)"),
114
+ 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)"),
67
115
  ) -> None:
68
116
  """Show build/check status for the PR opened from the current branch (Azure DevOps or GitHub, auto-detected)."""
69
117
  remote = current_remote()
@@ -73,6 +121,69 @@ def pr_status(
73
121
  pr_build.run(remote, pat, target_branch, wait)
74
122
 
75
123
 
124
+ @pr_app.command("update")
125
+ def pr_update(
126
+ title: str | None = typer.Option(None, "--title", help="New PR title"),
127
+ description: str | None = typer.Option(
128
+ None, "--description", help="New PR description (replaces the existing one)"
129
+ ),
130
+ screenshot: list[str] = typer.Option(
131
+ [], "--screenshot", help="Path to an image to append to the PR description (repeatable)"
132
+ ),
133
+ target: str = typer.Option("main", "--target", help="Target branch of the PR (Azure DevOps only)"),
134
+ pat: str | None = typer.Option(
135
+ None,
136
+ "--pat",
137
+ envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"],
138
+ help="Azure DevOps PAT (else falls back to `az` login)",
139
+ ),
140
+ ) -> None:
141
+ """Update the title/description of the PR opened from the current branch (Azure DevOps or GitHub, auto-detected)."""
142
+ for path in screenshot:
143
+ if not Path(path).is_file():
144
+ raise typer.BadParameter(f"Screenshot not found: {path}", param_hint="--screenshot")
145
+ if title is None and description is None and not screenshot:
146
+ raise typer.BadParameter("Provide at least one of --title, --description, --screenshot")
147
+
148
+ remote = current_remote()
149
+ source_branch = current_branch()
150
+ if isinstance(remote, GitHubRemote):
151
+ gh_pr.update(require_gh(), remote.owner, remote.repo, source_branch, title, description, screenshot)
152
+ else:
153
+ session, pr = _resolve_ado_pr(pat, remote, source_branch, target)
154
+ pr_build.update(session, remote, pr, title, description, screenshot)
155
+
156
+
157
+ @pr_app.command("comment")
158
+ def pr_comment(
159
+ message: str | None = typer.Option(None, "--message", help="Comment text"),
160
+ screenshot: list[str] = typer.Option(
161
+ [], "--screenshot", help="Path to an image to embed in the comment (repeatable)"
162
+ ),
163
+ target: str = typer.Option("main", "--target", help="Target branch of the PR (Azure DevOps only)"),
164
+ pat: str | None = typer.Option(
165
+ None,
166
+ "--pat",
167
+ envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"],
168
+ help="Azure DevOps PAT (else falls back to `az` login)",
169
+ ),
170
+ ) -> None:
171
+ """Post a comment on the PR opened from the current branch (Azure DevOps or GitHub, auto-detected)."""
172
+ for path in screenshot:
173
+ if not Path(path).is_file():
174
+ raise typer.BadParameter(f"Screenshot not found: {path}", param_hint="--screenshot")
175
+ if not message and not screenshot:
176
+ raise typer.BadParameter("Provide at least one of --message, --screenshot")
177
+
178
+ remote = current_remote()
179
+ source_branch = current_branch()
180
+ if isinstance(remote, GitHubRemote):
181
+ gh_pr.comment_with_screenshots(require_gh(), remote.owner, remote.repo, source_branch, message, screenshot)
182
+ else:
183
+ session, pr = _resolve_ado_pr(pat, remote, source_branch, target)
184
+ pr_build.comment_with_screenshots(session, remote, pr["pullRequestId"], message, screenshot)
185
+
186
+
76
187
  @app.command()
77
188
  def worktree(
78
189
  name: str,
@@ -0,0 +1,290 @@
1
+ """GitHub PR merge/check status and creation via the `gh` CLI.
2
+
3
+ Deliberately avoids `gh pr checks --json` — that flag was only added in a
4
+ later `gh` release than some machines still run (confirmed missing on gh
5
+ 2.45.0). Everything here is built on `gh pr view --json ...`, whose --json
6
+ support has been stable for a long time, plus statusCheckRollup entries
7
+ categorized ourselves using GitHub's documented GraphQL enums.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from .pr_markdown import build_screenshots_section
21
+
22
+ PR_VIEW_FIELDS = "number,title,baseRefName,mergeable,statusCheckRollup"
23
+
24
+ # GitHub has no API for uploading images to a PR description (only the web
25
+ # UI's drag-and-drop, which needs a browser session). The standard
26
+ # workaround: keep screenshots on their own orphan branch, one folder per
27
+ # source branch, and link to them with a raw blob URL.
28
+ SCREENSHOTS_BRANCH = "pr-assets"
29
+
30
+ # PullRequest.mergeable (GraphQL MergeableState).
31
+ CONFLICTING = "CONFLICTING"
32
+ UNKNOWN_MERGEABLE = "UNKNOWN"
33
+
34
+ # statusCheckRollup entries are a union of CheckRun | StatusContext.
35
+ # CheckRun.conclusion (GraphQL CheckConclusionState) -> bucket.
36
+ _CHECK_RUN_BUCKET = {
37
+ "SUCCESS": "pass",
38
+ "NEUTRAL": "pass",
39
+ "SKIPPED": "skipping",
40
+ "CANCELLED": "cancel",
41
+ "FAILURE": "fail",
42
+ "TIMED_OUT": "fail",
43
+ "ACTION_REQUIRED": "fail",
44
+ "STALE": "fail",
45
+ }
46
+ # Legacy commit Status.state (GraphQL StatusState) -> bucket.
47
+ _STATUS_CONTEXT_BUCKET = {
48
+ "SUCCESS": "pass",
49
+ "PENDING": "pending",
50
+ "EXPECTED": "pending",
51
+ "ERROR": "fail",
52
+ "FAILURE": "fail",
53
+ }
54
+
55
+
56
+ def _run_gh_json(gh: str, args: list[str]) -> dict:
57
+ r = subprocess.run([gh, *args], capture_output=True, encoding="utf-8")
58
+ if r.returncode != 0:
59
+ sys.exit((r.stderr or r.stdout).strip() or f"`gh {' '.join(args)}` failed")
60
+ return json.loads(r.stdout)
61
+
62
+
63
+ def get_pr(gh: str) -> dict:
64
+ """The PR for the current branch, however `gh` resolves it — there's no
65
+ target-branch filter on `gh pr view` the way ADO's search API has one.
66
+ """
67
+ return _run_gh_json(gh, ["pr", "view", "--json", PR_VIEW_FIELDS])
68
+
69
+
70
+ def check_bucket(check: dict) -> str:
71
+ if check.get("__typename") == "StatusContext":
72
+ return _STATUS_CONTEXT_BUCKET.get(check.get("state"), "pending")
73
+ if check.get("status") != "COMPLETED":
74
+ return "pending"
75
+ return _CHECK_RUN_BUCKET.get(check.get("conclusion"), "fail")
76
+
77
+
78
+ def check_label(check: dict) -> str:
79
+ name = check.get("name", "?")
80
+ workflow = check.get("workflowName")
81
+ return f"{workflow} / {name}" if workflow and workflow not in name else name
82
+
83
+
84
+ def merge_conflict_message(pr: dict) -> str | None:
85
+ if pr.get("mergeable") != CONFLICTING:
86
+ return None
87
+ return f"PR #{pr.get('number')} ({pr.get('title', '?')!r}) has merge conflicts with '{pr.get('baseRefName', '?')}' (mergeable=CONFLICTING)"
88
+
89
+
90
+ def print_check(check: dict) -> None:
91
+ bucket = check_bucket(check)
92
+ icon = {"pass": "✓", "fail": "✗", "cancel": "⊘"}.get(bucket, "…")
93
+ print(f" [{icon} {bucket.upper()}] {check_label(check)}")
94
+
95
+
96
+ def run(gh: str, wait: bool) -> None:
97
+ last_line = ""
98
+ while True:
99
+ pr = get_pr(gh)
100
+
101
+ # GitHub hasn't finished computing mergeability yet (usually resolves
102
+ # within a couple seconds); worth a short wait even outside --wait mode
103
+ # isn't safe (could spin forever if it never resolves) — only retry
104
+ # when the caller already opted into waiting.
105
+ if pr.get("mergeable") == UNKNOWN_MERGEABLE and wait:
106
+ time.sleep(3)
107
+ continue
108
+
109
+ conflict = merge_conflict_message(pr)
110
+ if conflict:
111
+ sys.exit(conflict)
112
+
113
+ pr_number = pr.get("number")
114
+ title = pr.get("title", "?")
115
+ base = pr.get("baseRefName", "?")
116
+ msg = f"\rPR #{pr_number}: {title} (base={base})"
117
+
118
+ checks = pr.get("statusCheckRollup") or []
119
+ if not checks:
120
+ print(msg + " | no checks found.")
121
+ return
122
+
123
+ buckets = [check_bucket(c) for c in checks]
124
+ msg += " | " + ", ".join(f"{check_label(c)}: {check_bucket(c)}" for c in checks)
125
+
126
+ if "pending" in buckets and wait:
127
+ if msg != last_line:
128
+ print(msg, end="", flush=True)
129
+ last_line = msg
130
+ time.sleep(30)
131
+ continue
132
+
133
+ print(msg)
134
+ print("\nDetails:")
135
+ for c in checks:
136
+ print_check(c)
137
+
138
+ if "fail" in buckets:
139
+ sys.exit(1)
140
+ return
141
+
142
+
143
+ def create(gh: str, target: str, extra_args: list[str]) -> int:
144
+ """Create a GitHub PR from the current branch into `target`.
145
+
146
+ --fill autofills title/body from commit info so this never blocks on an
147
+ interactive prompt; pass --title/--body in extra_args to override (gh
148
+ lets explicit values take precedence over --fill).
149
+ """
150
+ cmd = [gh, "pr", "create", "--base", target, "--fill", *extra_args]
151
+ return subprocess.run(cmd).returncode
152
+
153
+
154
+ def _git(args: list[str], env: dict[str, str] | None = None) -> str:
155
+ r = subprocess.run(["git", *args], capture_output=True, encoding="utf-8", env=env)
156
+ if r.returncode != 0:
157
+ sys.exit((r.stderr or r.stdout).strip() or f"`git {' '.join(args)}` failed")
158
+ return r.stdout.strip()
159
+
160
+
161
+ def push_screenshots(owner: str, repo: str, branch: str, paths: list[str], max_attempts: int = 5) -> list[str]:
162
+ """Push `paths` to a `<branch>/` folder on the `pr-assets` branch and return their raw blob URLs.
163
+
164
+ Built entirely from plumbing commands (hash-object/read-tree/write-tree/
165
+ commit-tree) against a throwaway index file, so nothing is checked out —
166
+ safe to call no matter what the current working tree looks like.
167
+
168
+ Tree paths are index-prefixed so two screenshots sharing a basename don't
169
+ overwrite each other. Retries on push rejection (another `pr create
170
+ --screenshot` moved the branch tip in the meantime) by re-fetching the new
171
+ tip and rebuilding the commit on top of it.
172
+ """
173
+ for attempt in range(1, max_attempts + 1):
174
+ remote_ref = subprocess.run(
175
+ ["git", "ls-remote", "origin", f"refs/heads/{SCREENSHOTS_BRANCH}"], capture_output=True, encoding="utf-8"
176
+ ).stdout.split()
177
+ parent = remote_ref[0] if remote_ref else None
178
+
179
+ with tempfile.TemporaryDirectory() as tmp:
180
+ env = {**os.environ, "GIT_INDEX_FILE": str(Path(tmp) / "index")}
181
+ if parent:
182
+ _git(["fetch", "origin", SCREENSHOTS_BRANCH], env=env)
183
+ _git(["read-tree", parent], env=env)
184
+
185
+ urls = []
186
+ for i, path in enumerate(paths):
187
+ blob_sha = _git(["hash-object", "-w", path])
188
+ tree_path = f"{branch}/{i:02d}-{Path(path).name}"
189
+ _git(["update-index", "--add", "--cacheinfo", f"100644,{blob_sha},{tree_path}"], env=env)
190
+ urls.append(f"https://github.com/{owner}/{repo}/blob/{SCREENSHOTS_BRANCH}/{tree_path}?raw=true")
191
+
192
+ tree_sha = _git(["write-tree"], env=env)
193
+
194
+ commit_args = ["commit-tree", tree_sha, "-m", f"screenshots: {branch}"]
195
+ if parent:
196
+ commit_args += ["-p", parent]
197
+ commit_sha = _git(commit_args)
198
+
199
+ push = subprocess.run(
200
+ ["git", "push", "origin", f"{commit_sha}:refs/heads/{SCREENSHOTS_BRANCH}"],
201
+ capture_output=True,
202
+ encoding="utf-8",
203
+ )
204
+ if push.returncode == 0:
205
+ return urls
206
+ if attempt == max_attempts:
207
+ sys.exit((push.stderr or push.stdout).strip() or "`git push` failed")
208
+
209
+ return [] # unreachable; loop always returns or exits
210
+
211
+
212
+ def _screenshot_images(owner: str, repo: str, branch: str, screenshot_paths: list[str]) -> list[tuple[str, str]]:
213
+ urls = push_screenshots(owner, repo, branch, screenshot_paths)
214
+ return list(zip((Path(p).name for p in screenshot_paths), urls))
215
+
216
+
217
+ def add_screenshots(gh: str, owner: str, repo: str, branch: str, screenshot_paths: list[str]) -> None:
218
+ """Push screenshots to the `pr-assets` branch and append them to the current branch's PR body."""
219
+ images = _screenshot_images(owner, repo, branch, screenshot_paths)
220
+ pr = _run_gh_json(gh, ["pr", "view", "--json", "number,body"])
221
+ body = build_screenshots_section(pr.get("body"), images)
222
+ r = subprocess.run([gh, "pr", "edit", str(pr["number"]), "--body", body], capture_output=True, encoding="utf-8")
223
+ if r.returncode != 0:
224
+ sys.exit((r.stderr or r.stdout).strip() or "`gh pr edit` failed")
225
+ print(f"Attached {len(screenshot_paths)} screenshot(s) to PR #{pr['number']}")
226
+
227
+
228
+ def update(
229
+ gh: str,
230
+ owner: str,
231
+ repo: str,
232
+ branch: str,
233
+ title: str | None = None,
234
+ description: str | None = None,
235
+ screenshot_paths: list[str] | None = None,
236
+ ) -> None:
237
+ """Update a PR's title and/or body, optionally appending screenshots to the body."""
238
+ pr = _run_gh_json(gh, ["pr", "view", "--json", "number,body"])
239
+ args = [gh, "pr", "edit", str(pr["number"])]
240
+ if title:
241
+ args += ["--title", title]
242
+ if description is not None or screenshot_paths:
243
+ new_body: str = description if description is not None else (pr.get("body") or "")
244
+ if screenshot_paths:
245
+ new_body = build_screenshots_section(new_body, _screenshot_images(owner, repo, branch, screenshot_paths))
246
+ args += ["--body", new_body]
247
+ if len(args) == 3:
248
+ return
249
+ r = subprocess.run(args, capture_output=True, encoding="utf-8")
250
+ if r.returncode != 0:
251
+ sys.exit((r.stderr or r.stdout).strip() or "`gh pr edit` failed")
252
+ print(f"Updated PR #{pr['number']}")
253
+
254
+
255
+ def comment_with_screenshots(gh: str, owner: str, repo: str, branch: str, message: str | None, screenshot_paths: list[str]) -> None:
256
+ """Post a comment, with a message and/or screenshots, on the current branch's PR."""
257
+ images = _screenshot_images(owner, repo, branch, screenshot_paths) if screenshot_paths else []
258
+ content = build_screenshots_section(message, images).strip() if images else (message or "")
259
+ r = subprocess.run([gh, "pr", "comment", "--body", content], capture_output=True, encoding="utf-8")
260
+ if r.returncode != 0:
261
+ sys.exit((r.stderr or r.stdout).strip() or "`gh pr comment` failed")
262
+ print(f"Added comment ({len(screenshot_paths)} screenshot(s)) to the current PR")
263
+
264
+
265
+ def protection_requires_status_checks(protection: dict) -> bool:
266
+ """True if a branch's `.../protection` response requires status checks to pass."""
267
+ return bool(protection.get("required_status_checks"))
268
+
269
+
270
+ def has_build_policy(gh: str, branch: str) -> bool:
271
+ """Best-effort check for whether `branch` has branch protection requiring status checks.
272
+
273
+ Only used to decide whether to print a `bdt pr status` reminder after
274
+ `pr create` — `{owner}`/`{repo}` are resolved by `gh` from the current
275
+ repo, and any failure (no permission to read protection settings, branch
276
+ not protected at all, etc.) fails open (returns False) rather than
277
+ blocking PR creation.
278
+ """
279
+ r = subprocess.run(
280
+ [gh, "api", f"repos/{{owner}}/{{repo}}/branches/{branch}/protection"],
281
+ capture_output=True,
282
+ encoding="utf-8",
283
+ )
284
+ if r.returncode != 0:
285
+ return False
286
+ try:
287
+ data = json.loads(r.stdout)
288
+ except json.JSONDecodeError:
289
+ return False
290
+ return protection_requires_status_checks(data)
@@ -5,12 +5,14 @@ from __future__ import annotations
5
5
  import re
6
6
  import sys
7
7
  import time
8
+ from pathlib import Path
8
9
  from urllib.parse import quote
9
10
 
10
11
  import requests
11
12
 
12
13
  from .ado_auth import auth_header
13
14
  from .gitrepo import AdoRemote, current_branch
15
+ from .pr_markdown import build_screenshots_section
14
16
 
15
17
  # Matches an ISO 8601 timestamp at the start of a log line, e.g. 2024-03-21T15:01:23.1234567Z
16
18
  TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s*")
@@ -55,7 +57,7 @@ def policy_configs_include_branch(configs: list, repo_id: str, branch: str, defa
55
57
  return False
56
58
 
57
59
 
58
- def has_build_policy(remote: AdoRemote, branch: str, pat: str | None = None) -> bool:
60
+ def has_build_policy(session: requests.Session, remote: AdoRemote, branch: str) -> bool:
59
61
  """Best-effort check for whether `branch` has an enabled Build policy configured.
60
62
 
61
63
  Only used to decide whether to print a `bdt pr status` reminder after
@@ -63,9 +65,6 @@ def has_build_policy(remote: AdoRemote, branch: str, pat: str | None = None) ->
63
65
  (return False) rather than blocking PR creation.
64
66
  """
65
67
  try:
66
- session = requests.Session()
67
- session.headers.update(auth_header(pat))
68
-
69
68
  r = session.get(
70
69
  f"{_base_url(remote)}/_apis/git/repositories/{quote(remote.repo, safe='')}",
71
70
  params={"api-version": "7.1"},
@@ -119,6 +118,95 @@ def get_pr(session: requests.Session, remote: AdoRemote, source_branch: str, tar
119
118
  sys.exit(1)
120
119
 
121
120
 
121
+ def upload_attachment(session: requests.Session, remote: AdoRemote, pr_id: int, attachment_name: str, file_path: str) -> str:
122
+ """Upload `file_path` as a pull request attachment named `attachment_name`; returns its download URL.
123
+
124
+ Embedding that URL in the PR description works because the browser
125
+ request for the image is same-origin (dev.azure.com) and carries the
126
+ viewer's own auth session/cookies — no separate hosting needed.
127
+ """
128
+ r = session.post(
129
+ f"{_base_url(remote)}/_apis/git/repositories/{quote(remote.repo, safe='')}"
130
+ f"/pullRequests/{pr_id}/attachments/{quote(attachment_name, safe='')}",
131
+ params={"api-version": "7.1"},
132
+ data=Path(file_path).read_bytes(),
133
+ headers={"Content-Type": "application/octet-stream"},
134
+ )
135
+ r.raise_for_status()
136
+ return r.json()["url"]
137
+
138
+
139
+ def _upload_screenshots(session: requests.Session, remote: AdoRemote, pr_id: int, screenshot_paths: list[str]) -> list[tuple[str, str]]:
140
+ """Upload each screenshot as a PR attachment; returns (display name, url) pairs.
141
+
142
+ Attachment names are index-prefixed so two screenshots sharing a basename
143
+ (e.g. two 'before.png' from different folders) don't overwrite each other.
144
+ """
145
+ return [
146
+ (Path(path).name, upload_attachment(session, remote, pr_id, f"{i:02d}-{Path(path).name}", path))
147
+ for i, path in enumerate(screenshot_paths)
148
+ ]
149
+
150
+
151
+ def _patch_pr(session: requests.Session, remote: AdoRemote, pr_id: int, fields: dict) -> None:
152
+ r = session.patch(
153
+ f"{_base_url(remote)}/_apis/git/repositories/{quote(remote.repo, safe='')}/pullRequests/{pr_id}",
154
+ params={"api-version": "7.1"},
155
+ json=fields,
156
+ )
157
+ r.raise_for_status()
158
+
159
+
160
+ def add_screenshots(session: requests.Session, remote: AdoRemote, pr: dict, screenshot_paths: list[str]) -> None:
161
+ """Upload each screenshot as a PR attachment and append them to the PR description."""
162
+ pr_id = pr["pullRequestId"]
163
+ images = _upload_screenshots(session, remote, pr_id, screenshot_paths)
164
+ _patch_pr(session, remote, pr_id, {"description": build_screenshots_section(pr.get("description"), images)})
165
+ print(f"Attached {len(screenshot_paths)} screenshot(s) to PR #{pr_id}")
166
+
167
+
168
+ def update(
169
+ session: requests.Session,
170
+ remote: AdoRemote,
171
+ pr: dict,
172
+ title: str | None = None,
173
+ description: str | None = None,
174
+ screenshot_paths: list[str] | None = None,
175
+ ) -> None:
176
+ """Update a PR's title and/or description, optionally appending screenshots to the description."""
177
+ pr_id = pr["pullRequestId"]
178
+ fields: dict = {}
179
+ if title:
180
+ fields["title"] = title
181
+ if description is not None or screenshot_paths:
182
+ new_description = description if description is not None else pr.get("description")
183
+ if screenshot_paths:
184
+ new_description = build_screenshots_section(new_description, _upload_screenshots(session, remote, pr_id, screenshot_paths))
185
+ fields["description"] = new_description
186
+ if not fields:
187
+ return
188
+ _patch_pr(session, remote, pr_id, fields)
189
+ print(f"Updated PR #{pr_id}")
190
+
191
+
192
+ def add_comment(session: requests.Session, remote: AdoRemote, pr_id: int, content: str) -> None:
193
+ """Post a new top-level comment thread on the PR."""
194
+ r = session.post(
195
+ f"{_base_url(remote)}/_apis/git/repositories/{quote(remote.repo, safe='')}/pullRequests/{pr_id}/threads",
196
+ params={"api-version": "7.1"},
197
+ json={"comments": [{"parentCommentId": 0, "content": content, "commentType": 1}], "status": 1},
198
+ )
199
+ r.raise_for_status()
200
+
201
+
202
+ def comment_with_screenshots(session: requests.Session, remote: AdoRemote, pr_id: int, message: str | None, screenshot_paths: list[str]) -> None:
203
+ """Post a comment, with a message and/or screenshots, on the PR."""
204
+ images = _upload_screenshots(session, remote, pr_id, screenshot_paths) if screenshot_paths else []
205
+ content = build_screenshots_section(message, images).strip() if images else (message or "")
206
+ add_comment(session, remote, pr_id, content)
207
+ print(f"Added comment ({len(screenshot_paths)} screenshot(s)) to PR #{pr_id}")
208
+
209
+
122
210
  def get_builds_for_pr(session: requests.Session, remote: AdoRemote, source_branch: str, pr_id: int) -> list:
123
211
  builds = []
124
212
  for ref in [f"refs/pull/{pr_id}/merge", f"refs/heads/{source_branch}"]:
@@ -0,0 +1,9 @@
1
+ """Markdown building shared by the Azure DevOps and GitHub screenshot-attach paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def build_screenshots_section(existing_text: str | None, images: list[tuple[str, str]]) -> str:
7
+ """Append a '## Screenshots' markdown section of `images` (name, url) to `existing_text`."""
8
+ section = "\n".join(f"![{name}]({url})" for name, url in images)
9
+ return f"{existing_text or ''}\n\n## Screenshots\n\n{section}\n"
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.2.2"
13
+ version = "0.3.0"
14
14
  description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.11"
@@ -51,7 +51,7 @@ current branch regardless, so the flag is ignored there — the PR's actual
51
51
  base branch is shown in the output instead.
52
52
 
53
53
  Auth: Azure DevOps works with no setup if the caller is already `az
54
- login`'d (only pass `--pat`/`AZURE_DEVOPS_PAT` if there's no `az` session
54
+ login`'d (only pass `--pat`/`AZURE_DEVOPS_EXT_PAT`/`AZURE_DEVOPS_PAT` if there's no `az` session
55
55
  available); GitHub uses whatever `gh auth login` session is active.
56
56
 
57
57
  ## Creating a PR
@@ -0,0 +1,13 @@
1
+ from bmsdna.devtools.pr_markdown import build_screenshots_section
2
+
3
+
4
+ def test_build_screenshots_section_appends_section_with_images() -> None:
5
+ text = build_screenshots_section("existing text", [("before.png", "https://x/1"), ("after.png", "https://x/2")])
6
+ assert text.startswith("existing text\n\n## Screenshots\n\n")
7
+ assert "![before.png](https://x/1)" in text
8
+ assert "![after.png](https://x/2)" in text
9
+
10
+
11
+ def test_build_screenshots_section_handles_no_existing_text() -> None:
12
+ text = build_screenshots_section(None, [("shot.png", "https://x/1")])
13
+ assert text == "\n\n## Screenshots\n\n![shot.png](https://x/1)\n"
@@ -13,7 +13,7 @@ wheels = [
13
13
 
14
14
  [[package]]
15
15
  name = "bmsdna-devtools"
16
- version = "0.2.2"
16
+ version = "0.3.0"
17
17
  source = { editable = "." }
18
18
  dependencies = [
19
19
  { name = "requests" },
@@ -1,168 +0,0 @@
1
- """GitHub PR merge/check status and creation via the `gh` CLI.
2
-
3
- Deliberately avoids `gh pr checks --json` — that flag was only added in a
4
- later `gh` release than some machines still run (confirmed missing on gh
5
- 2.45.0). Everything here is built on `gh pr view --json ...`, whose --json
6
- support has been stable for a long time, plus statusCheckRollup entries
7
- categorized ourselves using GitHub's documented GraphQL enums.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import json
13
- import subprocess
14
- import sys
15
- import time
16
-
17
- PR_VIEW_FIELDS = "number,title,baseRefName,mergeable,statusCheckRollup"
18
-
19
- # PullRequest.mergeable (GraphQL MergeableState).
20
- CONFLICTING = "CONFLICTING"
21
- UNKNOWN_MERGEABLE = "UNKNOWN"
22
-
23
- # statusCheckRollup entries are a union of CheckRun | StatusContext.
24
- # CheckRun.conclusion (GraphQL CheckConclusionState) -> bucket.
25
- _CHECK_RUN_BUCKET = {
26
- "SUCCESS": "pass",
27
- "NEUTRAL": "pass",
28
- "SKIPPED": "skipping",
29
- "CANCELLED": "cancel",
30
- "FAILURE": "fail",
31
- "TIMED_OUT": "fail",
32
- "ACTION_REQUIRED": "fail",
33
- "STALE": "fail",
34
- }
35
- # Legacy commit Status.state (GraphQL StatusState) -> bucket.
36
- _STATUS_CONTEXT_BUCKET = {
37
- "SUCCESS": "pass",
38
- "PENDING": "pending",
39
- "EXPECTED": "pending",
40
- "ERROR": "fail",
41
- "FAILURE": "fail",
42
- }
43
-
44
-
45
- def _run_gh_json(gh: str, args: list[str]) -> dict:
46
- r = subprocess.run([gh, *args], capture_output=True, encoding="utf-8")
47
- if r.returncode != 0:
48
- sys.exit((r.stderr or r.stdout).strip() or f"`gh {' '.join(args)}` failed")
49
- return json.loads(r.stdout)
50
-
51
-
52
- def get_pr(gh: str) -> dict:
53
- """The PR for the current branch, however `gh` resolves it — there's no
54
- target-branch filter on `gh pr view` the way ADO's search API has one.
55
- """
56
- return _run_gh_json(gh, ["pr", "view", "--json", PR_VIEW_FIELDS])
57
-
58
-
59
- def check_bucket(check: dict) -> str:
60
- if check.get("__typename") == "StatusContext":
61
- return _STATUS_CONTEXT_BUCKET.get(check.get("state"), "pending")
62
- if check.get("status") != "COMPLETED":
63
- return "pending"
64
- return _CHECK_RUN_BUCKET.get(check.get("conclusion"), "fail")
65
-
66
-
67
- def check_label(check: dict) -> str:
68
- name = check.get("name", "?")
69
- workflow = check.get("workflowName")
70
- return f"{workflow} / {name}" if workflow and workflow not in name else name
71
-
72
-
73
- def merge_conflict_message(pr: dict) -> str | None:
74
- if pr.get("mergeable") != CONFLICTING:
75
- return None
76
- return f"PR #{pr.get('number')} ({pr.get('title', '?')!r}) has merge conflicts with '{pr.get('baseRefName', '?')}' (mergeable=CONFLICTING)"
77
-
78
-
79
- def print_check(check: dict) -> None:
80
- bucket = check_bucket(check)
81
- icon = {"pass": "✓", "fail": "✗", "cancel": "⊘"}.get(bucket, "…")
82
- print(f" [{icon} {bucket.upper()}] {check_label(check)}")
83
-
84
-
85
- def run(gh: str, wait: bool) -> None:
86
- last_line = ""
87
- while True:
88
- pr = get_pr(gh)
89
-
90
- # GitHub hasn't finished computing mergeability yet (usually resolves
91
- # within a couple seconds); worth a short wait even outside --wait mode
92
- # isn't safe (could spin forever if it never resolves) — only retry
93
- # when the caller already opted into waiting.
94
- if pr.get("mergeable") == UNKNOWN_MERGEABLE and wait:
95
- time.sleep(3)
96
- continue
97
-
98
- conflict = merge_conflict_message(pr)
99
- if conflict:
100
- sys.exit(conflict)
101
-
102
- pr_number = pr.get("number")
103
- title = pr.get("title", "?")
104
- base = pr.get("baseRefName", "?")
105
- msg = f"\rPR #{pr_number}: {title} (base={base})"
106
-
107
- checks = pr.get("statusCheckRollup") or []
108
- if not checks:
109
- print(msg + " | no checks found.")
110
- return
111
-
112
- buckets = [check_bucket(c) for c in checks]
113
- msg += " | " + ", ".join(f"{check_label(c)}: {check_bucket(c)}" for c in checks)
114
-
115
- if "pending" in buckets and wait:
116
- if msg != last_line:
117
- print(msg, end="", flush=True)
118
- last_line = msg
119
- time.sleep(30)
120
- continue
121
-
122
- print(msg)
123
- print("\nDetails:")
124
- for c in checks:
125
- print_check(c)
126
-
127
- if "fail" in buckets:
128
- sys.exit(1)
129
- return
130
-
131
-
132
- def create(gh: str, target: str, extra_args: list[str]) -> int:
133
- """Create a GitHub PR from the current branch into `target`.
134
-
135
- --fill autofills title/body from commit info so this never blocks on an
136
- interactive prompt; pass --title/--body in extra_args to override (gh
137
- lets explicit values take precedence over --fill).
138
- """
139
- cmd = [gh, "pr", "create", "--base", target, "--fill", *extra_args]
140
- return subprocess.run(cmd).returncode
141
-
142
-
143
- def protection_requires_status_checks(protection: dict) -> bool:
144
- """True if a branch's `.../protection` response requires status checks to pass."""
145
- return bool(protection.get("required_status_checks"))
146
-
147
-
148
- def has_build_policy(gh: str, branch: str) -> bool:
149
- """Best-effort check for whether `branch` has branch protection requiring status checks.
150
-
151
- Only used to decide whether to print a `bdt pr status` reminder after
152
- `pr create` — `{owner}`/`{repo}` are resolved by `gh` from the current
153
- repo, and any failure (no permission to read protection settings, branch
154
- not protected at all, etc.) fails open (returns False) rather than
155
- blocking PR creation.
156
- """
157
- r = subprocess.run(
158
- [gh, "api", f"repos/{{owner}}/{{repo}}/branches/{branch}/protection"],
159
- capture_output=True,
160
- encoding="utf-8",
161
- )
162
- if r.returncode != 0:
163
- return False
164
- try:
165
- data = json.loads(r.stdout)
166
- except json.JSONDecodeError:
167
- return False
168
- return protection_requires_status_checks(data)