@danmoisan/drm-copilot-mcp 0.0.6 → 1.0.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.
Files changed (62) hide show
  1. package/out/mcp-server.js +10213 -398
  2. package/package.json +2 -2
  3. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +127 -17
  4. package/resources/claude-customizations/.claude/hooks/enforce-completion-helpers.ps1 +163 -0
  5. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +0 -12
  6. package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +68 -1
  7. package/resources/config/orchestration-routing.json +3 -3
  8. package/resources/codex-and-agents-customizations/.codex/scripts/post-codex-worktree-session.ps1 +0 -5
  9. package/resources/scripts/dev_tools/__init__.py +0 -0
  10. package/resources/scripts/dev_tools/_orchestrator_state_human_interaction.py +0 -127
  11. package/resources/scripts/dev_tools/_orchestrator_state_routing.py +0 -216
  12. package/resources/scripts/dev_tools/agentic_sync.py +0 -819
  13. package/resources/scripts/dev_tools/codex_native_converter/__init__.py +0 -11
  14. package/resources/scripts/dev_tools/codex_native_converter/__main__.py +0 -6
  15. package/resources/scripts/dev_tools/codex_native_converter/cli.py +0 -11
  16. package/resources/scripts/dev_tools/new_active_feature_folder.py +0 -79
  17. package/resources/scripts/dev_tools/new_active_feature_folder_docs.py +0 -268
  18. package/resources/scripts/dev_tools/new_active_feature_folder_flow.py +0 -366
  19. package/resources/scripts/dev_tools/new_active_feature_folder_io.py +0 -306
  20. package/resources/scripts/dev_tools/new_active_feature_folder_markdown.py +0 -252
  21. package/resources/scripts/dev_tools/new_active_feature_folder_models.py +0 -136
  22. package/resources/scripts/dev_tools/new_potential_bug_entry.py +0 -465
  23. package/resources/scripts/dev_tools/potential_to_issue.py +0 -421
  24. package/resources/scripts/dev_tools/potential_to_issue_content.py +0 -212
  25. package/resources/scripts/dev_tools/pr_context/__init__.py +0 -0
  26. package/resources/scripts/dev_tools/pr_context/collector.py +0 -619
  27. package/resources/scripts/dev_tools/pr_context/feature_docs.py +0 -349
  28. package/resources/scripts/dev_tools/pr_context/git.py +0 -153
  29. package/resources/scripts/dev_tools/pr_context/github.py +0 -549
  30. package/resources/scripts/dev_tools/pr_context/models.py +0 -198
  31. package/resources/scripts/dev_tools/pr_context/render.py +0 -342
  32. package/resources/scripts/dev_tools/pr_context/render_feature_excerpts.py +0 -256
  33. package/resources/scripts/dev_tools/pr_context/render_pr_helpers.py +0 -291
  34. package/resources/scripts/dev_tools/pr_context/summary_helpers.py +0 -386
  35. package/resources/scripts/dev_tools/pr_context/verification_evidence.py +0 -171
  36. package/resources/scripts/dev_tools/prompt_mode_contract.py +0 -152
  37. package/resources/scripts/dev_tools/push_down_claude_customizations.py +0 -403
  38. package/resources/scripts/dev_tools/push_down_claude_filesystem.py +0 -472
  39. package/resources/scripts/dev_tools/push_down_claude_pack_selection.py +0 -401
  40. package/resources/scripts/dev_tools/push_down_codex_and_agents_customizations.py +0 -139
  41. package/resources/scripts/dev_tools/push_down_copilot_customizations.py +0 -504
  42. package/resources/scripts/dev_tools/push_down_copilot_customizations_filesystem.py +0 -217
  43. package/resources/scripts/dev_tools/push_down_copilot_customizations_rewrites.py +0 -293
  44. package/resources/scripts/dev_tools/resolve_file_prompt.py +0 -457
  45. package/resources/scripts/dev_tools/resolve_hard_lock_prompt.py +0 -444
  46. package/resources/scripts/dev_tools/validate_orchestration_artifacts.py +0 -246
  47. package/resources/scripts/dev_tools/validate_orchestration_review_artifacts.py +0 -107
  48. package/resources/scripts/dev_tools/validate_orchestrator_state.py +0 -505
  49. package/resources/scripts/dev_tools/validate_policy_audit_artifact.py +0 -472
  50. package/resources/templates/codex_native_converter.py +0 -35
  51. package/resources/templates/collect_commit_context.py +0 -212
  52. package/resources/templates/collect_pr_context.py +0 -74
  53. package/resources/templates/hello_python.py +0 -11
  54. package/resources/templates/new_active_feature_folder.py +0 -67
  55. package/resources/templates/new_potential_bug_entry.py +0 -54
  56. package/resources/templates/potential_to_issue.py +0 -55
  57. package/resources/templates/push_down_claude_customizations.py +0 -233
  58. package/resources/templates/push_down_codex_and_agents_customizations.py +0 -95
  59. package/resources/templates/push_down_copilot_customizations.py +0 -124
  60. package/resources/templates/resolve_atomic_plan_prompt.py +0 -75
  61. package/resources/templates/resolve_hard_lock_prompt.py +0 -65
  62. package/resources/templates/validate_orchestration_artifacts.py +0 -55
@@ -1,549 +0,0 @@
1
- """GitHub CLI helpers for PR context collection."""
2
-
3
- from __future__ import annotations
4
-
5
- import base64
6
- import json
7
- import shutil
8
- from typing import TYPE_CHECKING, cast
9
-
10
- from .models import IssueDetails, PullRequestDetails, find_user_story_link
11
-
12
- if TYPE_CHECKING:
13
- from collections.abc import Sequence
14
- from pathlib import Path
15
-
16
- from .git import CommandRunner
17
-
18
-
19
- class GhClient:
20
- """GitHub CLI wrapper used for entity classification and content fetches."""
21
-
22
- def __init__(
23
- self, runner: CommandRunner, cwd: Path, gh_path: str | None = None
24
- ) -> None:
25
- self._runner = runner
26
- self._cwd = cwd
27
- self._gh_path = gh_path or shutil.which("gh")
28
- self._repo_cache: str | None = None
29
- self._availability_error: str | None = None
30
- self._available = False
31
- self._status_message: str | None = None
32
- self._hydrate_availability()
33
-
34
- @property
35
- def available(self) -> bool:
36
- return self._available
37
-
38
- def ensure_available(self) -> None:
39
- if not self._available:
40
- message = self._availability_error or "GitHub CLI is unavailable."
41
- raise RuntimeError(message)
42
-
43
- @property
44
- def status_message(self) -> str | None:
45
- return self._status_message
46
-
47
- def _hydrate_availability(self) -> None:
48
- if not self._gh_path:
49
- self._availability_error = "GitHub CLI (gh) is not installed. Install from https://cli.github.com/."
50
- return
51
-
52
- status = self._runner.run(
53
- [self._gh_path, "auth", "status"], cwd=self._cwd, allow_error=True
54
- )
55
- if status.code != 0:
56
- stderr = status.stderr or status.stdout or "unknown error"
57
- self._availability_error = (
58
- "GitHub CLI is installed but not authenticated. Run 'gh auth login' "
59
- "to authenticate."
60
- )
61
- if stderr:
62
- self._availability_error += f" Details: {stderr.strip()}"
63
- return
64
-
65
- repo = self._repo_name()
66
- if not repo:
67
- self._availability_error = (
68
- "GitHub CLI is authenticated but failed to resolve repository. "
69
- "Ensure network access and repository permissions."
70
- )
71
- return
72
-
73
- self._available = True
74
- self._availability_error = None
75
- self._status_message = f"GitHub CLI authenticated for {repo}"
76
-
77
- def _repo_name(self) -> str | None:
78
- if self._repo_cache:
79
- return self._repo_cache
80
- if not self._gh_path:
81
- return None
82
-
83
- result = self._runner.run(
84
- [self._gh_path, "repo", "view", "--json", "nameWithOwner"],
85
- cwd=self._cwd,
86
- allow_error=True,
87
- )
88
- if result.code != 0 or not result.stdout:
89
- return None
90
-
91
- try:
92
- payload = json.loads(result.stdout)
93
- except json.JSONDecodeError:
94
- return None
95
-
96
- if not isinstance(payload, dict):
97
- return None
98
-
99
- payload_dict = cast("dict[str, object]", payload)
100
- name_raw = payload_dict.get("nameWithOwner")
101
- if isinstance(name_raw, str):
102
- self._repo_cache = name_raw
103
- return name_raw
104
- return None
105
-
106
- def _request_json(
107
- self,
108
- args: Sequence[str],
109
- *,
110
- context: str,
111
- allow_not_found: bool = False,
112
- ) -> object:
113
- result = self._runner.run(args, cwd=self._cwd, allow_error=True)
114
- if result.code != 0:
115
- message = result.stderr or result.stdout or "unknown error"
116
- if allow_not_found and ("404" in message or "Not Found" in message):
117
- return None
118
- raise RuntimeError(f"{context} failed ({result.code}): {message.strip()}")
119
- try:
120
- return json.loads(result.stdout)
121
- except json.JSONDecodeError as exc: # pragma: no cover - defensive
122
- raise RuntimeError(f"{context} returned invalid JSON") from exc
123
-
124
- def classify_entity(self, number: str) -> str | None:
125
- """Return "issue" or "pull" if determinable, otherwise None."""
126
-
127
- self.ensure_available()
128
- repo = self._repo_name()
129
- if not repo:
130
- return None
131
-
132
- api_path = f"repos/{repo}/issues/{number}"
133
- try:
134
- payload = self._request_json(
135
- [self._gh_path or "gh", "api", api_path],
136
- context="Classify entity",
137
- allow_not_found=True,
138
- )
139
- except RuntimeError:
140
- return None
141
-
142
- if payload is None:
143
- return None
144
- if isinstance(payload, dict) and "pull_request" in payload:
145
- return "pull"
146
- return "issue"
147
-
148
- def closing_issues(self, pr_number: str | None = None) -> list[str]:
149
- self.ensure_available()
150
- repo = self._repo_name()
151
- if not repo:
152
- return []
153
-
154
- args = [
155
- self._gh_path or "gh",
156
- "pr",
157
- "view",
158
- "--json",
159
- "closingIssuesReferences,number",
160
- ]
161
- if pr_number:
162
- args.insert(3, pr_number)
163
-
164
- payload = self._request_json(args, context="Fetch closing issues")
165
-
166
- if not isinstance(payload, dict):
167
- return []
168
-
169
- payload_dict = cast("dict[str, object]", payload)
170
- issues_raw: object | None = payload_dict.get("closingIssuesReferences")
171
- numbers: list[str] = []
172
- if isinstance(issues_raw, list):
173
- issues_list = cast("list[object]", issues_raw)
174
- for entry in issues_list:
175
- if not isinstance(entry, dict):
176
- continue
177
-
178
- entry_dict = cast("dict[str, object]", entry)
179
- number = entry_dict.get("number")
180
- if isinstance(number, int):
181
- numbers.append(f"#{number}")
182
-
183
- return sorted(set(numbers))
184
-
185
- def issue_details(self, number: str) -> IssueDetails:
186
- self.ensure_available()
187
- repo = self._repo_name()
188
- if not repo:
189
- raise RuntimeError("Unable to resolve repository for issue lookup.")
190
-
191
- payload = self._request_json(
192
- [self._gh_path or "gh", "api", f"repos/{repo}/issues/{number}"],
193
- context="Fetch issue",
194
- )
195
- if not isinstance(payload, dict):
196
- raise RuntimeError("Unexpected issue payload format.")
197
-
198
- payload_dict = cast("dict[str, object]", payload)
199
-
200
- title_raw = payload_dict.get("title")
201
- title = title_raw if isinstance(title_raw, str) else ""
202
- state_raw = payload_dict.get("state")
203
- state = state_raw if isinstance(state_raw, str) else ""
204
-
205
- body_raw = payload_dict.get("body")
206
- body = body_raw if isinstance(body_raw, str) else ""
207
-
208
- labels_raw = payload_dict.get("labels")
209
- labels: list[str] = []
210
- if isinstance(labels_raw, list):
211
- for entry in cast("list[object]", labels_raw):
212
- if isinstance(entry, dict):
213
- entry_dict = cast("dict[str, object]", entry)
214
- name_raw = entry_dict.get("name")
215
- if isinstance(name_raw, str):
216
- labels.append(name_raw)
217
-
218
- assignees_raw = payload_dict.get("assignees")
219
- assignees: list[str] = []
220
- if isinstance(assignees_raw, list):
221
- for entry in cast("list[object]", assignees_raw):
222
- if isinstance(entry, dict):
223
- entry_dict = cast("dict[str, object]", entry)
224
- login_raw = entry_dict.get("login")
225
- if isinstance(login_raw, str):
226
- assignees.append(login_raw)
227
-
228
- user_raw = payload_dict.get("user")
229
- author = ""
230
- if isinstance(user_raw, dict):
231
- user_dict = cast("dict[str, object]", user_raw)
232
- login_raw = user_dict.get("login")
233
- author = login_raw if isinstance(login_raw, str) else ""
234
-
235
- created_raw = payload_dict.get("created_at")
236
- updated_raw = payload_dict.get("updated_at")
237
- created_at = created_raw if isinstance(created_raw, str) else ""
238
- updated_at = updated_raw if isinstance(updated_raw, str) else ""
239
-
240
- comments_url_raw = payload_dict.get("comments_url")
241
- comments_url = comments_url_raw if isinstance(comments_url_raw, str) else None
242
-
243
- comments: list[str] = []
244
- if comments_url:
245
- comment_payload = self._request_json(
246
- [self._gh_path or "gh", "api", f"{comments_url}?per_page=50"],
247
- context="Fetch issue comments",
248
- )
249
- if isinstance(comment_payload, list):
250
- comment_entries = cast("list[object]", comment_payload)
251
- for entry in comment_entries:
252
- if not isinstance(entry, dict):
253
- continue
254
- entry_dict = cast("dict[str, object]", entry)
255
- user_raw = entry_dict.get("user")
256
- login = ""
257
- if isinstance(user_raw, dict):
258
- user_dict = cast("dict[str, object]", user_raw)
259
- login_raw = user_dict.get("login")
260
- if isinstance(login_raw, str):
261
- login = login_raw
262
- created_raw = entry_dict.get("created_at")
263
- created = created_raw if isinstance(created_raw, str) else ""
264
- entry_body_raw = entry_dict.get("body")
265
- entry_body = (
266
- entry_body_raw if isinstance(entry_body_raw, str) else ""
267
- )
268
- prefix = f"{login}" if login else "(unknown)"
269
- if created:
270
- prefix = f"{prefix} at {created}"
271
- comments.append(f"{prefix}: {entry_body}".strip())
272
-
273
- story_path = find_user_story_link(body)
274
- story_content = None
275
- if story_path:
276
- local_path = self._cwd / story_path
277
- if local_path.exists():
278
- story_content = local_path.read_text(encoding="utf-8")
279
- else:
280
- story_content = self.fetch_repo_file(story_path)
281
-
282
- return IssueDetails(
283
- number=f"#{number}",
284
- title=title or "(no title)",
285
- state=state or "(unknown)",
286
- labels=labels,
287
- assignees=assignees,
288
- author=author or "(unknown)",
289
- created_at=created_at,
290
- updated_at=updated_at,
291
- body=body or "(no body)",
292
- comments=comments,
293
- user_story_path=story_path,
294
- user_story_content=story_content,
295
- )
296
-
297
- def pr_details(self, number: str) -> PullRequestDetails:
298
- self.ensure_available()
299
- repo = self._repo_name()
300
- if not repo:
301
- raise RuntimeError("Unable to resolve repository for PR lookup.")
302
-
303
- payload = self._request_json(
304
- [
305
- self._gh_path or "gh",
306
- "pr",
307
- "view",
308
- number,
309
- "--json",
310
- (
311
- "number,title,body,state,author,baseRefName,headRefName,"
312
- "createdAt,updatedAt,mergedAt,labels,assignees,"
313
- "closingIssuesReferences,files"
314
- ),
315
- ],
316
- context="Fetch pull request",
317
- )
318
- if not isinstance(payload, dict):
319
- raise RuntimeError("Unexpected pull request payload format.")
320
-
321
- payload_dict = cast("dict[str, object]", payload)
322
-
323
- title_raw = payload_dict.get("title")
324
- title = title_raw if isinstance(title_raw, str) else ""
325
- state_raw = payload_dict.get("state")
326
- state = state_raw if isinstance(state_raw, str) else ""
327
-
328
- body_raw = payload_dict.get("body")
329
- body = body_raw if isinstance(body_raw, str) else ""
330
- closing: list[str] = []
331
- closing_raw = payload_dict.get("closingIssuesReferences")
332
- if isinstance(closing_raw, list):
333
- for entry in cast("list[object]", closing_raw):
334
- if isinstance(entry, dict):
335
- entry_dict = cast("dict[str, object]", entry)
336
- num_raw = entry_dict.get("number")
337
- if isinstance(num_raw, int):
338
- closing.append(f"#{num_raw}")
339
-
340
- labels_raw = payload_dict.get("labels")
341
- labels: list[str] = []
342
- if isinstance(labels_raw, list):
343
- for entry in cast("list[object]", labels_raw):
344
- if isinstance(entry, dict):
345
- entry_dict = cast("dict[str, object]", entry)
346
- name_raw = entry_dict.get("name")
347
- if isinstance(name_raw, str):
348
- labels.append(name_raw)
349
-
350
- assignees_raw = payload_dict.get("assignees")
351
- assignees: list[str] = []
352
- if isinstance(assignees_raw, list):
353
- for entry in cast("list[object]", assignees_raw):
354
- if isinstance(entry, dict):
355
- entry_dict = cast("dict[str, object]", entry)
356
- login_raw = entry_dict.get("login")
357
- if isinstance(login_raw, str):
358
- assignees.append(login_raw)
359
-
360
- author_raw = payload_dict.get("author")
361
- author_login = ""
362
- if isinstance(author_raw, dict):
363
- author_dict = cast("dict[str, object]", author_raw)
364
- author_login_raw = author_dict.get("login")
365
- author_login = author_login_raw if isinstance(author_login_raw, str) else ""
366
-
367
- base_ref_raw = payload_dict.get("baseRefName")
368
- head_ref_raw = payload_dict.get("headRefName")
369
- created_raw = payload_dict.get("createdAt")
370
- updated_raw = payload_dict.get("updatedAt")
371
- merged_raw = payload_dict.get("mergedAt")
372
- files_raw = payload_dict.get("files")
373
- files_changed: list[str] = []
374
- if isinstance(files_raw, list):
375
- for entry in cast("list[object]", files_raw):
376
- if isinstance(entry, dict):
377
- entry_dict = cast("dict[str, object]", entry)
378
- path_raw = entry_dict.get("path")
379
- if isinstance(path_raw, str):
380
- files_changed.append(path_raw)
381
-
382
- return PullRequestDetails(
383
- number=f"#{number}",
384
- title=title or "(no title)",
385
- state=state or "(unknown)",
386
- author=author_login or "(unknown)",
387
- base_ref=base_ref_raw if isinstance(base_ref_raw, str) else "(unknown)",
388
- head_ref=head_ref_raw if isinstance(head_ref_raw, str) else "(unknown)",
389
- created_at=created_raw if isinstance(created_raw, str) else "",
390
- updated_at=updated_raw if isinstance(updated_raw, str) else "",
391
- merged_at=merged_raw if isinstance(merged_raw, str) else None,
392
- labels=labels,
393
- assignees=assignees,
394
- body=body or "(no body)",
395
- closing_issues=closing,
396
- files_changed=files_changed,
397
- )
398
-
399
- def current_pr(self) -> PullRequestDetails | None:
400
- if not self._available:
401
- return None
402
-
403
- args = [
404
- self._gh_path or "gh",
405
- "pr",
406
- "view",
407
- "--json",
408
- (
409
- "number,title,body,state,author,baseRefName,headRefName,"
410
- "createdAt,updatedAt,mergedAt,labels,assignees,closingIssuesReferences"
411
- ),
412
- ]
413
- result = self._runner.run(args, cwd=self._cwd, allow_error=True)
414
- if result.code != 0 or not result.stdout:
415
- return None
416
-
417
- try:
418
- payload = json.loads(result.stdout)
419
- except json.JSONDecodeError:
420
- return None
421
-
422
- if not isinstance(payload, dict):
423
- return None
424
-
425
- payload_dict = cast("dict[str, object]", payload)
426
-
427
- closing_raw = payload_dict.get("closingIssuesReferences")
428
- closing: list[str] = []
429
- if isinstance(closing_raw, list):
430
- closing_entries = cast("list[object]", closing_raw)
431
- for entry in closing_entries:
432
- if not isinstance(entry, dict):
433
- continue
434
- entry_dict = cast("dict[str, object]", entry)
435
- number = entry_dict.get("number")
436
- if isinstance(number, int):
437
- closing.append(f"#{number}")
438
-
439
- title_raw = payload_dict.get("title")
440
- title = title_raw if isinstance(title_raw, str) else ""
441
- body_raw = payload_dict.get("body")
442
- body = body_raw if isinstance(body_raw, str) else ""
443
- number_raw = payload_dict.get("number")
444
- number = number_raw if isinstance(number_raw, int) else None
445
- if number is None:
446
- return None
447
-
448
- state_raw = payload_dict.get("state")
449
- state = state_raw if isinstance(state_raw, str) else ""
450
- author_raw = payload_dict.get("author")
451
- author_login = ""
452
- if isinstance(author_raw, dict):
453
- author_dict = cast("dict[str, object]", author_raw)
454
- author_login_raw = author_dict.get("login")
455
- author_login = author_login_raw if isinstance(author_login_raw, str) else ""
456
- base_ref_raw = payload_dict.get("baseRefName")
457
- head_ref_raw = payload_dict.get("headRefName")
458
- created_raw = payload_dict.get("createdAt")
459
- updated_raw = payload_dict.get("updatedAt")
460
- merged_raw = payload_dict.get("mergedAt")
461
- labels_raw = payload_dict.get("labels")
462
- labels: list[str] = []
463
- if isinstance(labels_raw, list):
464
- for entry in cast("list[object]", labels_raw):
465
- if isinstance(entry, dict):
466
- entry_dict = cast("dict[str, object]", entry)
467
- name_raw = entry_dict.get("name")
468
- if isinstance(name_raw, str):
469
- labels.append(name_raw)
470
- assignees_raw = payload_dict.get("assignees")
471
- assignees: list[str] = []
472
- if isinstance(assignees_raw, list):
473
- for entry in cast("list[object]", assignees_raw):
474
- if isinstance(entry, dict):
475
- entry_dict = cast("dict[str, object]", entry)
476
- login_raw = entry_dict.get("login")
477
- if isinstance(login_raw, str):
478
- assignees.append(login_raw)
479
-
480
- return PullRequestDetails(
481
- number=f"#{number}",
482
- title=title or "(no title)",
483
- state=state or "(unknown)",
484
- author=author_login or "(unknown)",
485
- base_ref=base_ref_raw if isinstance(base_ref_raw, str) else "(unknown)",
486
- head_ref=head_ref_raw if isinstance(head_ref_raw, str) else "(unknown)",
487
- created_at=created_raw if isinstance(created_raw, str) else "",
488
- updated_at=updated_raw if isinstance(updated_raw, str) else "",
489
- merged_at=merged_raw if isinstance(merged_raw, str) else None,
490
- labels=labels,
491
- assignees=assignees,
492
- body=body or "(no body)",
493
- closing_issues=sorted(set(closing)),
494
- files_changed=[],
495
- )
496
-
497
- def fetch_repo_file(self, path: str) -> str | None:
498
- self.ensure_available()
499
- repo = self._repo_name()
500
- if not repo:
501
- return None
502
-
503
- api_path = f"repos/{repo}/contents/{path.lstrip('/')}"
504
- try:
505
- payload = self._request_json(
506
- [self._gh_path or "gh", "api", api_path], context="Fetch file"
507
- )
508
- except RuntimeError:
509
- return None
510
-
511
- if not isinstance(payload, dict):
512
- return None
513
-
514
- payload_dict = cast("dict[str, object]", payload)
515
-
516
- content_raw = payload_dict.get("content")
517
- if not isinstance(content_raw, str):
518
- return None
519
-
520
- try:
521
- decoded = base64.b64decode(content_raw)
522
- except (ValueError, TypeError): # pragma: no cover - defensive
523
- return None
524
- return decoded.decode("utf-8", errors="replace")
525
-
526
- def ci_status(self, head_sha: str) -> tuple[str | None, list[str]]:
527
- self.ensure_available()
528
- args = [
529
- self._gh_path or "gh",
530
- "run",
531
- "list",
532
- "--commit",
533
- head_sha,
534
- "--limit",
535
- "1",
536
- "--json",
537
- "conclusion,status,name,headSha",
538
- ]
539
- payload = self._request_json(args, context="Fetch CI status")
540
- if not isinstance(payload, list) or not payload:
541
- return None, []
542
- runs = cast("list[object]", payload)
543
- first_obj = runs[0]
544
- if not isinstance(first_obj, dict):
545
- return None, []
546
- first = cast("dict[str, object]", first_obj)
547
- status_raw = first.get("status") or first.get("conclusion")
548
- status = status_raw if isinstance(status_raw, str) else None
549
- return status, []