msdevflow 0.6.0
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.
- package/bin/msdevflow.js +4 -0
- package/lib/bootstrap.js +923 -0
- package/package.json +28 -0
- package/skill/msdevflow/README.md +365 -0
- package/skill/msdevflow/SKILL.md +125 -0
- package/skill/msdevflow/references/ci-and-review.md +129 -0
- package/skill/msdevflow/references/code-review.md +138 -0
- package/skill/msdevflow/references/command-capabilities.md +86 -0
- package/skill/msdevflow/references/create-issue.md +118 -0
- package/skill/msdevflow/references/design-and-development.md +75 -0
- package/skill/msdevflow/references/openlibing-ci.md +260 -0
- package/skill/msdevflow/references/pr-and-ci.md +111 -0
- package/skill/msdevflow/references/recovery.md +91 -0
- package/skill/msdevflow/references/review-and-merge.md +104 -0
- package/skill/msdevflow/references/setup-and-issue.md +165 -0
- package/skill/msdevflow/references/state-and-safety.md +164 -0
- package/skill/msdevflow/scripts/openlibing_ci.py +574 -0
- package/skill/msdevflow/scripts/requirements.txt +1 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import importlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable
|
|
19
|
+
|
|
20
|
+
BASE_URL = "https://www.openlibing.com"
|
|
21
|
+
FAILURE_STATUSES = {"FAILED", "FAILURE", "ERROR"}
|
|
22
|
+
|
|
23
|
+
if os.name == "nt":
|
|
24
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
25
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
26
|
+
ERROR_PATTERN = re.compile(
|
|
27
|
+
r"\[\s*FAILED\s*\]|Finished:\s*FAILURE|Expected:|Actual:|Assertion|"
|
|
28
|
+
r"Segmentation fault|script returned exit code|:\d+(?::\d+)?:\s*(?:fatal\s+)?error:",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OpenLibingAuthRequired(RuntimeError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OpenLibingBrowserRequired(OpenLibingAuthRequired):
|
|
38
|
+
def __init__(self, message: str, oauth_url: str) -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.oauth_url = oauth_url
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def oauth_authorization_url(base_url: str = BASE_URL) -> str:
|
|
44
|
+
return f"{base_url.rstrip('/')}/gateway/oauth2/authorization/gitcode"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def find_browser() -> str | None:
|
|
48
|
+
candidates = [
|
|
49
|
+
os.getenv("OPENLIBING_BROWSER_PATH", ""),
|
|
50
|
+
os.path.expandvars(r"%PROGRAMFILES%\Google\Chrome\Application\chrome.exe"),
|
|
51
|
+
os.path.expandvars(r"%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe"),
|
|
52
|
+
os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
|
|
53
|
+
os.path.expandvars(r"%PROGRAMFILES%\Microsoft\Edge\Application\msedge.exe"),
|
|
54
|
+
os.path.expandvars(r"%PROGRAMFILES(X86)%\Microsoft\Edge\Application\msedge.exe"),
|
|
55
|
+
os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe"),
|
|
56
|
+
"/usr/bin/google-chrome",
|
|
57
|
+
"/usr/bin/google-chrome-stable",
|
|
58
|
+
"/usr/bin/chromium",
|
|
59
|
+
"/usr/bin/chromium-browser",
|
|
60
|
+
"/usr/bin/microsoft-edge",
|
|
61
|
+
"/usr/bin/microsoft-edge-stable",
|
|
62
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
63
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
64
|
+
]
|
|
65
|
+
return next((path for path in candidates if path and Path(path).is_file()), None)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def default_profile_dir() -> Path:
|
|
69
|
+
configured = os.getenv("OPENLIBING_PROFILE_DIR")
|
|
70
|
+
return Path(configured).expanduser() if configured else Path.home() / ".claude" / "openlibing-browser-profile"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
PROFILE_MARKER = ".msdevflow-openlibing-profile"
|
|
74
|
+
PROFILE_MARKER_CONTENT = "managed-by=msdevflow\n"
|
|
75
|
+
LEGACY_PROFILE_MARKERS = {
|
|
76
|
+
".gitcode-e2e-openlibing-profile": {
|
|
77
|
+
"managed-by=gitcode-e2e-workflow\n",
|
|
78
|
+
PROFILE_MARKER_CONTENT,
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
PROFILE_METADATA = "session.json"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def profile_marker(profile_dir: Path) -> Path:
|
|
85
|
+
return profile_dir / PROFILE_MARKER
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def valid_marker(marker: Path, accepted_contents: set[str]) -> bool:
|
|
89
|
+
try:
|
|
90
|
+
return marker.is_file() and marker.read_text(encoding="utf-8") in accepted_contents
|
|
91
|
+
except OSError:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def managed_profile_marker(profile_dir: Path) -> Path | None:
|
|
96
|
+
marker = profile_marker(profile_dir)
|
|
97
|
+
if valid_marker(marker, {PROFILE_MARKER_CONTENT}):
|
|
98
|
+
return marker
|
|
99
|
+
return next((
|
|
100
|
+
profile_dir / name
|
|
101
|
+
for name, contents in LEGACY_PROFILE_MARKERS.items()
|
|
102
|
+
if valid_marker(profile_dir / name, contents)
|
|
103
|
+
), None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def prepare_profile_dir(profile_dir: Path) -> None:
|
|
107
|
+
existing_marker = managed_profile_marker(profile_dir)
|
|
108
|
+
if profile_dir.is_dir() and existing_marker is None and any(profile_dir.iterdir()):
|
|
109
|
+
raise OpenLibingAuthRequired("拒绝使用:非空目录不是由 msdevflow 管理的 openLiBing profile。")
|
|
110
|
+
profile_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
111
|
+
if os.name != "nt":
|
|
112
|
+
profile_dir.chmod(stat.S_IRWXU)
|
|
113
|
+
marker = profile_marker(profile_dir)
|
|
114
|
+
marker.write_text(PROFILE_MARKER_CONTENT, encoding="utf-8")
|
|
115
|
+
if os.name != "nt":
|
|
116
|
+
marker.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
117
|
+
for legacy_name, contents in LEGACY_PROFILE_MARKERS.items():
|
|
118
|
+
legacy_marker = profile_dir / legacy_name
|
|
119
|
+
if valid_marker(legacy_marker, contents):
|
|
120
|
+
legacy_marker.unlink()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def write_profile_metadata(profile_dir: Path, browser_path: str | None) -> None:
|
|
124
|
+
metadata = profile_dir / PROFILE_METADATA
|
|
125
|
+
previous: dict[str, Any] = {}
|
|
126
|
+
if metadata.is_file():
|
|
127
|
+
try:
|
|
128
|
+
previous = json.loads(metadata.read_text(encoding="utf-8"))
|
|
129
|
+
except (OSError, ValueError):
|
|
130
|
+
previous = {}
|
|
131
|
+
now = int(time.time())
|
|
132
|
+
metadata.write_text(json.dumps({
|
|
133
|
+
"created_at": previous.get("created_at", now),
|
|
134
|
+
"last_used_at": now,
|
|
135
|
+
"browser": Path(browser_path).name if browser_path else "playwright-chromium",
|
|
136
|
+
"stores": "gitcode-browser-session; openlibing-storage-cleared-after-use",
|
|
137
|
+
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
138
|
+
if os.name != "nt":
|
|
139
|
+
metadata.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def profile_status(profile_dir: Path) -> dict[str, Any]:
|
|
143
|
+
managed = managed_profile_marker(profile_dir) is not None
|
|
144
|
+
result: dict[str, Any] = {
|
|
145
|
+
"profile_dir": str(profile_dir),
|
|
146
|
+
"exists": profile_dir.is_dir(),
|
|
147
|
+
"managed": managed,
|
|
148
|
+
"cookie_values_read": False,
|
|
149
|
+
}
|
|
150
|
+
metadata = profile_dir / PROFILE_METADATA
|
|
151
|
+
if managed and metadata.is_file():
|
|
152
|
+
try:
|
|
153
|
+
result["metadata"] = json.loads(metadata.read_text(encoding="utf-8"))
|
|
154
|
+
except (OSError, ValueError):
|
|
155
|
+
result["metadata"] = "unavailable"
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def clear_profile_dir(profile_dir: Path) -> None:
|
|
160
|
+
resolved = profile_dir.resolve()
|
|
161
|
+
if managed_profile_marker(resolved) is None:
|
|
162
|
+
raise RuntimeError("拒绝删除:目录不是由 msdevflow 管理的 openLiBing profile。")
|
|
163
|
+
shutil.rmtree(resolved)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def clear_openlibing_storage(context: Any, page: Any, base_url: str) -> None:
|
|
167
|
+
page.goto("about:blank")
|
|
168
|
+
host = urllib.parse.urlparse(base_url).hostname or "openlibing.com"
|
|
169
|
+
context.clear_cookies(domain=re.compile(rf"(^|\.){re.escape(host)}$"))
|
|
170
|
+
try:
|
|
171
|
+
cdp = context.new_cdp_session(page)
|
|
172
|
+
cdp.send("Storage.clearDataForOrigin", {
|
|
173
|
+
"origin": base_url,
|
|
174
|
+
"storageTypes": "all",
|
|
175
|
+
})
|
|
176
|
+
cdp.detach()
|
|
177
|
+
except Exception:
|
|
178
|
+
# Cookie removal is mandatory; non-Cookie storage cleanup is best-effort.
|
|
179
|
+
pass
|
|
180
|
+
if context.cookies([base_url]):
|
|
181
|
+
raise OpenLibingAuthRequired("无法从持久 profile 清除 openLiBing Cookie,已拒绝保存会话。")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def oauth_requirements_file() -> Path:
|
|
185
|
+
return Path(__file__).with_name("requirements.txt")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def ensure_playwright() -> None:
|
|
189
|
+
try:
|
|
190
|
+
importlib.import_module("playwright.sync_api")
|
|
191
|
+
return
|
|
192
|
+
except ImportError:
|
|
193
|
+
requirements = oauth_requirements_file()
|
|
194
|
+
if not requirements.is_file():
|
|
195
|
+
raise OpenLibingAuthRequired("缺少 openLiBing OAuth 依赖清单,无法自动安装 Playwright。")
|
|
196
|
+
result = subprocess.run(
|
|
197
|
+
[sys.executable, "-m", "pip", "install", "-r", str(requirements)],
|
|
198
|
+
check=False,
|
|
199
|
+
)
|
|
200
|
+
if result.returncode != 0:
|
|
201
|
+
raise OpenLibingAuthRequired("自动安装 Playwright 失败。")
|
|
202
|
+
importlib.invalidate_caches()
|
|
203
|
+
try:
|
|
204
|
+
importlib.import_module("playwright.sync_api")
|
|
205
|
+
except ImportError as error:
|
|
206
|
+
raise OpenLibingAuthRequired("Playwright 安装完成但当前 Python 无法导入。") from error
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def playwright_chromium_path() -> Path:
|
|
210
|
+
from playwright.sync_api import sync_playwright
|
|
211
|
+
|
|
212
|
+
with sync_playwright() as playwright:
|
|
213
|
+
return Path(playwright.chromium.executable_path)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def ensure_playwright_browser(base_url: str = BASE_URL) -> str | None:
|
|
217
|
+
executable = find_browser()
|
|
218
|
+
if executable:
|
|
219
|
+
return executable
|
|
220
|
+
try:
|
|
221
|
+
if playwright_chromium_path().is_file():
|
|
222
|
+
return None
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
raise OpenLibingBrowserRequired(
|
|
226
|
+
"没有可由 Playwright 控制的 Chrome/Edge。请复制 oauth_url 到浏览器继续登录;本次固定 run 尚未验证。",
|
|
227
|
+
oauth_authorization_url(base_url),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def interactive_oauth_login(
|
|
232
|
+
base_url: str = BASE_URL,
|
|
233
|
+
timeout_seconds: int = 300,
|
|
234
|
+
profile_dir: Path | None = None,
|
|
235
|
+
persistent: bool = True,
|
|
236
|
+
) -> str:
|
|
237
|
+
ensure_playwright()
|
|
238
|
+
executable = ensure_playwright_browser(base_url)
|
|
239
|
+
from playwright.sync_api import sync_playwright
|
|
240
|
+
|
|
241
|
+
selected_profile = (profile_dir or default_profile_dir()).resolve()
|
|
242
|
+
if persistent:
|
|
243
|
+
try:
|
|
244
|
+
prepare_profile_dir(selected_profile)
|
|
245
|
+
except OSError as error:
|
|
246
|
+
raise OpenLibingAuthRequired("无法准备专用浏览器 profile。") from error
|
|
247
|
+
with sync_playwright() as playwright:
|
|
248
|
+
launch_options: dict[str, Any] = {"headless": False}
|
|
249
|
+
if executable:
|
|
250
|
+
launch_options["executable_path"] = executable
|
|
251
|
+
browser = None
|
|
252
|
+
try:
|
|
253
|
+
if persistent:
|
|
254
|
+
context = playwright.chromium.launch_persistent_context(
|
|
255
|
+
user_data_dir=str(selected_profile),
|
|
256
|
+
**launch_options,
|
|
257
|
+
)
|
|
258
|
+
else:
|
|
259
|
+
browser = playwright.chromium.launch(**launch_options)
|
|
260
|
+
context = browser.new_context()
|
|
261
|
+
except Exception as error:
|
|
262
|
+
raise OpenLibingBrowserRequired(
|
|
263
|
+
"Playwright 无法启动可见浏览器。请复制 oauth_url 到浏览器继续登录;本次固定 run 尚未验证。",
|
|
264
|
+
oauth_authorization_url(base_url),
|
|
265
|
+
) from error
|
|
266
|
+
page = context.pages[0] if context.pages else context.new_page()
|
|
267
|
+
deadline = time.monotonic() + timeout_seconds
|
|
268
|
+
next_progress = time.monotonic()
|
|
269
|
+
token = ""
|
|
270
|
+
try:
|
|
271
|
+
print(
|
|
272
|
+
"请在打开的浏览器中完成 GitCode 登录,并在授权页手动点击授权。专用 profile 会保存 GitCode 浏览器会话;openLiBing token 不会持久化。",
|
|
273
|
+
file=sys.stderr,
|
|
274
|
+
)
|
|
275
|
+
page.goto(oauth_authorization_url(base_url), wait_until="domcontentloaded", timeout=45_000)
|
|
276
|
+
while time.monotonic() < deadline:
|
|
277
|
+
for cookie in context.cookies([base_url]):
|
|
278
|
+
if cookie.get("name") == "token":
|
|
279
|
+
token = str(cookie.get("value", ""))
|
|
280
|
+
break
|
|
281
|
+
if token:
|
|
282
|
+
break
|
|
283
|
+
if time.monotonic() >= next_progress:
|
|
284
|
+
current = urllib.parse.urlparse(page.url)
|
|
285
|
+
stage = "waiting-user-consent" if current.netloc == "gitcode.com" and current.path.startswith("/oauth/authorize") else "waiting-openlibing-callback"
|
|
286
|
+
print(f"{stage}:{current.scheme}://{current.netloc}{current.path}", file=sys.stderr)
|
|
287
|
+
next_progress = time.monotonic() + 15
|
|
288
|
+
page.wait_for_timeout(1000)
|
|
289
|
+
if not token:
|
|
290
|
+
raise OpenLibingAuthRequired("OAuth 登录超时,未获得 openLiBing 会话。")
|
|
291
|
+
if persistent:
|
|
292
|
+
write_profile_metadata(selected_profile, executable)
|
|
293
|
+
clear_openlibing_storage(context, page, base_url)
|
|
294
|
+
finally:
|
|
295
|
+
context.close()
|
|
296
|
+
if browser is not None:
|
|
297
|
+
browser.close()
|
|
298
|
+
return token
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class OpenLibingReadClient:
|
|
302
|
+
def __init__(
|
|
303
|
+
self,
|
|
304
|
+
base_url: str = BASE_URL,
|
|
305
|
+
allow_oauth: bool = False,
|
|
306
|
+
oauth_login: Callable[[], str] | None = None,
|
|
307
|
+
) -> None:
|
|
308
|
+
self.base_url = base_url.rstrip("/")
|
|
309
|
+
self.allow_oauth = allow_oauth
|
|
310
|
+
self.oauth_login = oauth_login or (lambda: interactive_oauth_login(self.base_url))
|
|
311
|
+
self._token = ""
|
|
312
|
+
self.auth_path = "anonymous-read"
|
|
313
|
+
|
|
314
|
+
def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
315
|
+
headers = {
|
|
316
|
+
"Accept": "application/json",
|
|
317
|
+
"Content-Type": "application/json",
|
|
318
|
+
"User-Agent": "msdevflow/openlibing-read",
|
|
319
|
+
}
|
|
320
|
+
if self._token:
|
|
321
|
+
headers["Csrf-Token-Open-Li-Bing"] = self._token
|
|
322
|
+
request = urllib.request.Request(
|
|
323
|
+
self.base_url + path,
|
|
324
|
+
data=json.dumps(payload).encode() if payload is not None else None,
|
|
325
|
+
headers=headers,
|
|
326
|
+
method=method,
|
|
327
|
+
)
|
|
328
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
329
|
+
return json.loads(response.read().decode())
|
|
330
|
+
|
|
331
|
+
def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
332
|
+
try:
|
|
333
|
+
return self._request(method, path, payload)
|
|
334
|
+
except urllib.error.HTTPError as error:
|
|
335
|
+
if error.code not in (401, 403) or self._token:
|
|
336
|
+
raise
|
|
337
|
+
if not self.allow_oauth:
|
|
338
|
+
raise OpenLibingAuthRequired(
|
|
339
|
+
"openLiBing 只读接口要求认证。经用户确认后使用 --oauth,或连接已部署的 BFF/Adapter。"
|
|
340
|
+
) from error
|
|
341
|
+
self._token = self.oauth_login()
|
|
342
|
+
self.auth_path = "interactive-oauth"
|
|
343
|
+
return self._request(method, path, payload)
|
|
344
|
+
|
|
345
|
+
def authenticate(self) -> None:
|
|
346
|
+
self._token = self.oauth_login()
|
|
347
|
+
self.auth_path = "interactive-oauth"
|
|
348
|
+
|
|
349
|
+
def detail(self, project_id: str, pipeline_id: str, run_id: str) -> dict[str, Any]:
|
|
350
|
+
query = urllib.parse.urlencode({
|
|
351
|
+
"projectId": project_id,
|
|
352
|
+
"pipelineId": pipeline_id,
|
|
353
|
+
"pipelineRunId": run_id,
|
|
354
|
+
})
|
|
355
|
+
return self.request(
|
|
356
|
+
"GET",
|
|
357
|
+
f"/gateway/openlibing-cicd/project/pipeline/pipeline-run/detail?{query}",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def logs(
|
|
361
|
+
self,
|
|
362
|
+
project_id: str,
|
|
363
|
+
pipeline_id: str,
|
|
364
|
+
run_id: str,
|
|
365
|
+
job_run_id: str,
|
|
366
|
+
step_run_id: str,
|
|
367
|
+
limit: int,
|
|
368
|
+
) -> str:
|
|
369
|
+
response = self.request(
|
|
370
|
+
"POST",
|
|
371
|
+
"/gateway/openlibing-cicd/project/pipeline/exec-log",
|
|
372
|
+
{
|
|
373
|
+
"projectId": project_id,
|
|
374
|
+
"pipelineId": pipeline_id,
|
|
375
|
+
"pipelineRunId": run_id,
|
|
376
|
+
"jobRunId": job_run_id,
|
|
377
|
+
"stepRunId": step_run_id,
|
|
378
|
+
"sort": "desc",
|
|
379
|
+
"limit": limit,
|
|
380
|
+
"startOffset": 0,
|
|
381
|
+
"endOffset": 0,
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
return str((response.get("data") or {}).get("log", ""))
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def failed_steps(detail: dict[str, Any]) -> list[dict[str, str]]:
|
|
388
|
+
failures: list[dict[str, str]] = []
|
|
389
|
+
for stage in (detail.get("data") or {}).get("stages", []):
|
|
390
|
+
for job in stage.get("jobs", []):
|
|
391
|
+
job_failed = str(job.get("status", "")).upper() in FAILURE_STATUSES
|
|
392
|
+
for step in job.get("steps", []):
|
|
393
|
+
step_failed = str(step.get("status", "")).upper() in FAILURE_STATUSES
|
|
394
|
+
if job_failed or step_failed:
|
|
395
|
+
failures.append({
|
|
396
|
+
"stage": str(stage.get("name", "")),
|
|
397
|
+
"job": str(job.get("name", "")),
|
|
398
|
+
"job_status": str(job.get("status", "")),
|
|
399
|
+
"job_run_id": str(job.get("id", "")),
|
|
400
|
+
"step": str(step.get("name", "")),
|
|
401
|
+
"step_status": str(step.get("status", "")),
|
|
402
|
+
"step_run_id": str(step.get("id", "")),
|
|
403
|
+
})
|
|
404
|
+
return failures
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def error_excerpt(log: str, context: int = 3) -> list[str]:
|
|
408
|
+
lines = log.splitlines()
|
|
409
|
+
selected: set[int] = set()
|
|
410
|
+
for index, line in enumerate(lines):
|
|
411
|
+
if ERROR_PATTERN.search(line):
|
|
412
|
+
selected.update(range(max(0, index - context), min(len(lines), index + context + 1)))
|
|
413
|
+
return [lines[index] for index in sorted(selected)][-120:]
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def oauth_login_for_args(args: argparse.Namespace) -> Callable[[], str]:
|
|
417
|
+
return lambda: interactive_oauth_login(
|
|
418
|
+
profile_dir=Path(args.profile_dir).expanduser() if args.profile_dir else None,
|
|
419
|
+
persistent=not args.temporary_session,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def diagnose(args: argparse.Namespace) -> int:
|
|
424
|
+
client = OpenLibingReadClient(
|
|
425
|
+
allow_oauth=args.oauth,
|
|
426
|
+
oauth_login=oauth_login_for_args(args),
|
|
427
|
+
)
|
|
428
|
+
detail = client.detail(args.project_id, args.pipeline_id, args.run_id)
|
|
429
|
+
failures = failed_steps(detail)
|
|
430
|
+
result: dict[str, Any] = {
|
|
431
|
+
"auth_path": client.auth_path,
|
|
432
|
+
"run": {
|
|
433
|
+
"id": str((detail.get("data") or {}).get("id", args.run_id)),
|
|
434
|
+
"number": (detail.get("data") or {}).get("run_number"),
|
|
435
|
+
"status": (detail.get("data") or {}).get("status"),
|
|
436
|
+
},
|
|
437
|
+
"failures": [],
|
|
438
|
+
}
|
|
439
|
+
for failure in failures:
|
|
440
|
+
log = client.logs(
|
|
441
|
+
args.project_id,
|
|
442
|
+
args.pipeline_id,
|
|
443
|
+
args.run_id,
|
|
444
|
+
failure["job_run_id"],
|
|
445
|
+
failure["step_run_id"],
|
|
446
|
+
args.limit,
|
|
447
|
+
)
|
|
448
|
+
result["failures"].append({**failure, "error_excerpt": error_excerpt(log)})
|
|
449
|
+
result["auth_path"] = client.auth_path
|
|
450
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def login_check(args: argparse.Namespace) -> int:
|
|
455
|
+
client = OpenLibingReadClient(
|
|
456
|
+
allow_oauth=True,
|
|
457
|
+
oauth_login=oauth_login_for_args(args),
|
|
458
|
+
)
|
|
459
|
+
client.authenticate()
|
|
460
|
+
detail = client.detail(args.project_id, args.pipeline_id, args.run_id)
|
|
461
|
+
data = detail.get("data") or {}
|
|
462
|
+
print(json.dumps({
|
|
463
|
+
"auth_path": client.auth_path,
|
|
464
|
+
"authenticated": True,
|
|
465
|
+
"run": {
|
|
466
|
+
"id": str(data.get("id", args.run_id)),
|
|
467
|
+
"number": data.get("run_number"),
|
|
468
|
+
"status": data.get("status"),
|
|
469
|
+
},
|
|
470
|
+
"token_output": "redacted",
|
|
471
|
+
"token_persistence": "process-memory-only",
|
|
472
|
+
"browser_session": "temporary" if args.temporary_session else "persistent-dedicated-profile",
|
|
473
|
+
"profile_dir": None if args.temporary_session else str(
|
|
474
|
+
Path(args.profile_dir).expanduser() if args.profile_dir else default_profile_dir()
|
|
475
|
+
),
|
|
476
|
+
}, ensure_ascii=False, indent=2))
|
|
477
|
+
return 0
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def session_status(args: argparse.Namespace) -> int:
|
|
481
|
+
selected = Path(args.profile_dir).expanduser() if args.profile_dir else default_profile_dir()
|
|
482
|
+
print(json.dumps(profile_status(selected.resolve()), ensure_ascii=False, indent=2))
|
|
483
|
+
return 0
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def session_clear(args: argparse.Namespace) -> int:
|
|
487
|
+
selected = Path(args.profile_dir).expanduser() if args.profile_dir else default_profile_dir()
|
|
488
|
+
if not args.yes:
|
|
489
|
+
print(json.dumps({
|
|
490
|
+
"error": "confirmation-required",
|
|
491
|
+
"profile_dir": str(selected),
|
|
492
|
+
"message": "清除专用浏览器会话需要 --yes。",
|
|
493
|
+
}, ensure_ascii=False))
|
|
494
|
+
return 4
|
|
495
|
+
clear_profile_dir(selected)
|
|
496
|
+
print(json.dumps({"cleared": True, "profile_dir": str(selected)}, ensure_ascii=False))
|
|
497
|
+
return 0
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def add_profile_arguments(command: argparse.ArgumentParser) -> None:
|
|
501
|
+
command.add_argument(
|
|
502
|
+
"--profile-dir",
|
|
503
|
+
default="",
|
|
504
|
+
help="Dedicated browser profile directory (default: ~/.claude/openlibing-browser-profile)",
|
|
505
|
+
)
|
|
506
|
+
command.add_argument(
|
|
507
|
+
"--temporary-session",
|
|
508
|
+
action="store_true",
|
|
509
|
+
help="Do not persist the dedicated GitCode browser session",
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def add_run_arguments(command: argparse.ArgumentParser) -> None:
|
|
514
|
+
command.add_argument("--project-id", required=True)
|
|
515
|
+
command.add_argument("--pipeline-id", required=True)
|
|
516
|
+
command.add_argument("--run-id", required=True)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def main() -> int:
|
|
520
|
+
parser = argparse.ArgumentParser(description="Safe openLiBing read-only CI diagnostics")
|
|
521
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
522
|
+
command = subparsers.add_parser("diagnose", help="Read run detail and failed step logs")
|
|
523
|
+
add_run_arguments(command)
|
|
524
|
+
add_profile_arguments(command)
|
|
525
|
+
command.add_argument("--limit", type=int, default=5000)
|
|
526
|
+
command.add_argument(
|
|
527
|
+
"--oauth",
|
|
528
|
+
action="store_true",
|
|
529
|
+
help="On 401/403, open a local browser for interactive OAuth; never prints or stores token",
|
|
530
|
+
)
|
|
531
|
+
login_command = subparsers.add_parser(
|
|
532
|
+
"login-check",
|
|
533
|
+
help="Force an interactive OAuth login, then verify it with a read-only run request",
|
|
534
|
+
)
|
|
535
|
+
add_run_arguments(login_command)
|
|
536
|
+
add_profile_arguments(login_command)
|
|
537
|
+
status_command = subparsers.add_parser(
|
|
538
|
+
"session-status",
|
|
539
|
+
help="Show dedicated profile metadata without reading cookie values",
|
|
540
|
+
)
|
|
541
|
+
status_command.add_argument("--profile-dir", default="")
|
|
542
|
+
clear_command = subparsers.add_parser(
|
|
543
|
+
"session-clear",
|
|
544
|
+
help="Delete a managed dedicated browser profile",
|
|
545
|
+
)
|
|
546
|
+
clear_command.add_argument("--profile-dir", default="")
|
|
547
|
+
clear_command.add_argument("--yes", action="store_true")
|
|
548
|
+
args = parser.parse_args()
|
|
549
|
+
try:
|
|
550
|
+
if args.command == "login-check":
|
|
551
|
+
return login_check(args)
|
|
552
|
+
if args.command == "session-status":
|
|
553
|
+
return session_status(args)
|
|
554
|
+
if args.command == "session-clear":
|
|
555
|
+
return session_clear(args)
|
|
556
|
+
return diagnose(args)
|
|
557
|
+
except OpenLibingBrowserRequired as error:
|
|
558
|
+
print(json.dumps({
|
|
559
|
+
"error": "browser-required",
|
|
560
|
+
"message": str(error),
|
|
561
|
+
"oauth_url": error.oauth_url,
|
|
562
|
+
"verification": "not-completed",
|
|
563
|
+
}, ensure_ascii=False))
|
|
564
|
+
return 5
|
|
565
|
+
except OpenLibingAuthRequired as error:
|
|
566
|
+
print(json.dumps({"error": "openlibing-auth-required", "message": str(error)}, ensure_ascii=False))
|
|
567
|
+
return 3
|
|
568
|
+
except urllib.error.HTTPError as error:
|
|
569
|
+
print(json.dumps({"error": "openlibing-http-error", "status": error.code}, ensure_ascii=False))
|
|
570
|
+
return 2
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
if __name__ == "__main__":
|
|
574
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
playwright>=1.54,<2
|