@danmoisan/drm-copilot-mcp 0.0.5 → 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 (96) hide show
  1. package/out/mcp-server.js +10268 -380
  2. package/package.json +2 -2
  3. package/resources/claude-customizations/.claude/agents/orchestrator.md +6 -2
  4. package/resources/claude-customizations/.claude/agents/pr-author.md +78 -0
  5. package/resources/claude-customizations/.claude/agents/task-researcher.md +10 -6
  6. package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +68 -10
  7. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +148 -11
  8. package/resources/claude-customizations/.claude/hooks/enforce-completion-helpers.ps1 +163 -0
  9. package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +40 -14
  10. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +198 -0
  11. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +153 -10
  12. package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +68 -1
  13. package/resources/claude-customizations/.claude/hooks/validate-pr-author-output.ps1 +136 -0
  14. package/resources/claude-customizations/.claude/hooks/validate-task-researcher-output.ps1 +22 -6
  15. package/resources/claude-customizations/.claude/settings.json +22 -0
  16. package/resources/claude-customizations/.claude/skills/evidence-and-timestamp-conventions/SKILL.md +0 -1
  17. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +76 -2
  18. package/resources/claude-customizations/.claude/skills/research-issue/SKILL.md +5 -3
  19. package/resources/claude-customizations/.claude-variants/csharp-legacy/agents/csharp-typed-engineer.md +69 -0
  20. package/resources/claude-customizations/.claude-variants/csharp-legacy/rules/csharp.md +96 -0
  21. package/resources/claude-customizations/.claude-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +77 -0
  22. package/resources/claude-customizations/.claude-variants/csharp-legacy/skills/invoke-csharp-engineer/SKILL.md +64 -0
  23. package/resources/claude-customizations/pack-manifests/core.json +65 -0
  24. package/resources/claude-customizations/pack-manifests/csharp-legacy.json +11 -0
  25. package/resources/claude-customizations/pack-manifests/csharp-modern.json +12 -0
  26. package/resources/claude-customizations/pack-manifests/powershell.json +14 -0
  27. package/resources/claude-customizations/pack-manifests/python.json +14 -0
  28. package/resources/claude-customizations/pack-manifests/typescript.json +9 -0
  29. package/resources/codex-and-agents-customizations/.agents/skills/feature-promotion-lifecycle/SKILL.md +30 -13
  30. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +113 -10
  31. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-workflow/SKILL.md +33 -8
  32. package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +12 -7
  33. package/resources/codex-and-agents-customizations/.codex/config.toml +65 -6
  34. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-checkpoint-monotonic.ps1 +303 -0
  35. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-completion-consistency.ps1 +300 -0
  36. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +148 -0
  37. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-pr-author-skill.ps1 +336 -0
  38. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +83 -17
  39. package/resources/config/orchestration-routing.json +5 -13
  40. package/resources/customizations/.github/agents/pr-author.agent.md +25 -0
  41. package/resources/customizations/.github/agents/task-researcher.agent.md +4 -4
  42. package/resources/customizations/.github/prompts/fillout-prd-feature.prompt.md +1 -1
  43. package/resources/customizations/.github/prompts/research-issue.prompt.md +4 -3
  44. package/resources/codex-and-agents-customizations/.codex/scripts/post-codex-worktree-session.ps1 +0 -5
  45. package/resources/scripts/dev_tools/__init__.py +0 -0
  46. package/resources/scripts/dev_tools/_orchestrator_state_human_interaction.py +0 -127
  47. package/resources/scripts/dev_tools/_orchestrator_state_routing.py +0 -216
  48. package/resources/scripts/dev_tools/agentic_sync.py +0 -819
  49. package/resources/scripts/dev_tools/codex_native_converter/__init__.py +0 -11
  50. package/resources/scripts/dev_tools/codex_native_converter/__main__.py +0 -6
  51. package/resources/scripts/dev_tools/codex_native_converter/cli.py +0 -11
  52. package/resources/scripts/dev_tools/new_active_feature_folder.py +0 -79
  53. package/resources/scripts/dev_tools/new_active_feature_folder_docs.py +0 -268
  54. package/resources/scripts/dev_tools/new_active_feature_folder_flow.py +0 -366
  55. package/resources/scripts/dev_tools/new_active_feature_folder_io.py +0 -306
  56. package/resources/scripts/dev_tools/new_active_feature_folder_markdown.py +0 -252
  57. package/resources/scripts/dev_tools/new_active_feature_folder_models.py +0 -136
  58. package/resources/scripts/dev_tools/new_potential_bug_entry.py +0 -465
  59. package/resources/scripts/dev_tools/potential_to_issue.py +0 -421
  60. package/resources/scripts/dev_tools/potential_to_issue_content.py +0 -212
  61. package/resources/scripts/dev_tools/pr_context/__init__.py +0 -0
  62. package/resources/scripts/dev_tools/pr_context/collector.py +0 -619
  63. package/resources/scripts/dev_tools/pr_context/feature_docs.py +0 -349
  64. package/resources/scripts/dev_tools/pr_context/git.py +0 -153
  65. package/resources/scripts/dev_tools/pr_context/github.py +0 -549
  66. package/resources/scripts/dev_tools/pr_context/models.py +0 -198
  67. package/resources/scripts/dev_tools/pr_context/render.py +0 -342
  68. package/resources/scripts/dev_tools/pr_context/render_feature_excerpts.py +0 -256
  69. package/resources/scripts/dev_tools/pr_context/render_pr_helpers.py +0 -291
  70. package/resources/scripts/dev_tools/pr_context/summary_helpers.py +0 -386
  71. package/resources/scripts/dev_tools/pr_context/verification_evidence.py +0 -171
  72. package/resources/scripts/dev_tools/prompt_mode_contract.py +0 -152
  73. package/resources/scripts/dev_tools/push_down_claude_customizations.py +0 -374
  74. package/resources/scripts/dev_tools/push_down_codex_and_agents_customizations.py +0 -139
  75. package/resources/scripts/dev_tools/push_down_copilot_customizations.py +0 -504
  76. package/resources/scripts/dev_tools/push_down_copilot_customizations_filesystem.py +0 -217
  77. package/resources/scripts/dev_tools/push_down_copilot_customizations_rewrites.py +0 -293
  78. package/resources/scripts/dev_tools/resolve_file_prompt.py +0 -457
  79. package/resources/scripts/dev_tools/resolve_hard_lock_prompt.py +0 -444
  80. package/resources/scripts/dev_tools/validate_orchestration_artifacts.py +0 -246
  81. package/resources/scripts/dev_tools/validate_orchestration_review_artifacts.py +0 -107
  82. package/resources/scripts/dev_tools/validate_orchestrator_state.py +0 -428
  83. package/resources/scripts/dev_tools/validate_policy_audit_artifact.py +0 -448
  84. package/resources/templates/codex_native_converter.py +0 -35
  85. package/resources/templates/collect_commit_context.py +0 -212
  86. package/resources/templates/collect_pr_context.py +0 -74
  87. package/resources/templates/hello_python.py +0 -11
  88. package/resources/templates/new_active_feature_folder.py +0 -67
  89. package/resources/templates/new_potential_bug_entry.py +0 -54
  90. package/resources/templates/potential_to_issue.py +0 -55
  91. package/resources/templates/push_down_claude_customizations.py +0 -409
  92. package/resources/templates/push_down_codex_and_agents_customizations.py +0 -95
  93. package/resources/templates/push_down_copilot_customizations.py +0 -124
  94. package/resources/templates/resolve_atomic_plan_prompt.py +0 -75
  95. package/resources/templates/resolve_hard_lock_prompt.py +0 -65
  96. 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, []