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.
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/PKG-INFO +3 -3
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/README.md +1 -1
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/cli.py +115 -4
- bmsdna_devtools-0.3.0/bmsdna/devtools/gh_pr.py +290 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/pr_build.py +92 -4
- bmsdna_devtools-0.3.0/bmsdna/devtools/pr_markdown.py +9 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/skills/bmsdna-devtools/SKILL.md +1 -1
- bmsdna_devtools-0.3.0/tests/test_pr_markdown.py +13 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/uv.lock +1 -1
- bmsdna_devtools-0.2.2/bmsdna/devtools/gh_pr.py +0 -168
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/.gitignore +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/.python-version +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/justfile +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.2.2 → bmsdna_devtools-0.3.0}/tests/test_gitrepo.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: bmsdna-devtools
|
|
3
|
-
Version: 0.
|
|
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",
|
|
89
|
+
"--source-branch", source_branch,
|
|
50
90
|
"--auto-complete", "false",
|
|
51
91
|
*(args or []),
|
|
52
92
|
]
|
|
53
93
|
returncode = subprocess.run(cmd).returncode
|
|
54
|
-
|
|
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(
|
|
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"" 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.
|
|
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 "" in text
|
|
8
|
+
assert "" 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\n"
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|