@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.
- package/out/mcp-server.js +10213 -398
- package/package.json +2 -2
- package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +127 -17
- package/resources/claude-customizations/.claude/hooks/enforce-completion-helpers.ps1 +163 -0
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +0 -12
- package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +68 -1
- package/resources/config/orchestration-routing.json +3 -3
- package/resources/codex-and-agents-customizations/.codex/scripts/post-codex-worktree-session.ps1 +0 -5
- package/resources/scripts/dev_tools/__init__.py +0 -0
- package/resources/scripts/dev_tools/_orchestrator_state_human_interaction.py +0 -127
- package/resources/scripts/dev_tools/_orchestrator_state_routing.py +0 -216
- package/resources/scripts/dev_tools/agentic_sync.py +0 -819
- package/resources/scripts/dev_tools/codex_native_converter/__init__.py +0 -11
- package/resources/scripts/dev_tools/codex_native_converter/__main__.py +0 -6
- package/resources/scripts/dev_tools/codex_native_converter/cli.py +0 -11
- package/resources/scripts/dev_tools/new_active_feature_folder.py +0 -79
- package/resources/scripts/dev_tools/new_active_feature_folder_docs.py +0 -268
- package/resources/scripts/dev_tools/new_active_feature_folder_flow.py +0 -366
- package/resources/scripts/dev_tools/new_active_feature_folder_io.py +0 -306
- package/resources/scripts/dev_tools/new_active_feature_folder_markdown.py +0 -252
- package/resources/scripts/dev_tools/new_active_feature_folder_models.py +0 -136
- package/resources/scripts/dev_tools/new_potential_bug_entry.py +0 -465
- package/resources/scripts/dev_tools/potential_to_issue.py +0 -421
- package/resources/scripts/dev_tools/potential_to_issue_content.py +0 -212
- package/resources/scripts/dev_tools/pr_context/__init__.py +0 -0
- package/resources/scripts/dev_tools/pr_context/collector.py +0 -619
- package/resources/scripts/dev_tools/pr_context/feature_docs.py +0 -349
- package/resources/scripts/dev_tools/pr_context/git.py +0 -153
- package/resources/scripts/dev_tools/pr_context/github.py +0 -549
- package/resources/scripts/dev_tools/pr_context/models.py +0 -198
- package/resources/scripts/dev_tools/pr_context/render.py +0 -342
- package/resources/scripts/dev_tools/pr_context/render_feature_excerpts.py +0 -256
- package/resources/scripts/dev_tools/pr_context/render_pr_helpers.py +0 -291
- package/resources/scripts/dev_tools/pr_context/summary_helpers.py +0 -386
- package/resources/scripts/dev_tools/pr_context/verification_evidence.py +0 -171
- package/resources/scripts/dev_tools/prompt_mode_contract.py +0 -152
- package/resources/scripts/dev_tools/push_down_claude_customizations.py +0 -403
- package/resources/scripts/dev_tools/push_down_claude_filesystem.py +0 -472
- package/resources/scripts/dev_tools/push_down_claude_pack_selection.py +0 -401
- package/resources/scripts/dev_tools/push_down_codex_and_agents_customizations.py +0 -139
- package/resources/scripts/dev_tools/push_down_copilot_customizations.py +0 -504
- package/resources/scripts/dev_tools/push_down_copilot_customizations_filesystem.py +0 -217
- package/resources/scripts/dev_tools/push_down_copilot_customizations_rewrites.py +0 -293
- package/resources/scripts/dev_tools/resolve_file_prompt.py +0 -457
- package/resources/scripts/dev_tools/resolve_hard_lock_prompt.py +0 -444
- package/resources/scripts/dev_tools/validate_orchestration_artifacts.py +0 -246
- package/resources/scripts/dev_tools/validate_orchestration_review_artifacts.py +0 -107
- package/resources/scripts/dev_tools/validate_orchestrator_state.py +0 -505
- package/resources/scripts/dev_tools/validate_policy_audit_artifact.py +0 -472
- package/resources/templates/codex_native_converter.py +0 -35
- package/resources/templates/collect_commit_context.py +0 -212
- package/resources/templates/collect_pr_context.py +0 -74
- package/resources/templates/hello_python.py +0 -11
- package/resources/templates/new_active_feature_folder.py +0 -67
- package/resources/templates/new_potential_bug_entry.py +0 -54
- package/resources/templates/potential_to_issue.py +0 -55
- package/resources/templates/push_down_claude_customizations.py +0 -233
- package/resources/templates/push_down_codex_and_agents_customizations.py +0 -95
- package/resources/templates/push_down_copilot_customizations.py +0 -124
- package/resources/templates/resolve_atomic_plan_prompt.py +0 -75
- package/resources/templates/resolve_hard_lock_prompt.py +0 -65
- 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, []
|