multiagentos 0.4.0__py3-none-any.whl

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 (122) hide show
  1. agents/__init__.py +1 -0
  2. agents/catalog.py +52 -0
  3. agents/registry.py +11 -0
  4. core/__init__.py +38 -0
  5. core/artifacts.py +47 -0
  6. core/chat_agent_bridge.py +280 -0
  7. core/chat_agent_registry.py +61 -0
  8. core/chat_agent_router.py +85 -0
  9. core/chat_session.py +62 -0
  10. core/contracts/__init__.py +22 -0
  11. core/contracts/agent.py +21 -0
  12. core/contracts/ai.py +25 -0
  13. core/contracts/capability.py +44 -0
  14. core/contracts/chat_agent.py +34 -0
  15. core/contracts/checkpoint.py +78 -0
  16. core/contracts/discovery.py +30 -0
  17. core/contracts/execution.py +28 -0
  18. core/contracts/github.py +74 -0
  19. core/contracts/handoff.py +56 -0
  20. core/contracts/health.py +206 -0
  21. core/contracts/human_review.py +20 -0
  22. core/contracts/ide.py +113 -0
  23. core/contracts/mcp.py +76 -0
  24. core/contracts/model_runtime.py +25 -0
  25. core/contracts/planning.py +25 -0
  26. core/contracts/profile.py +66 -0
  27. core/contracts/quota.py +62 -0
  28. core/contracts/repository.py +34 -0
  29. core/contracts/resume.py +55 -0
  30. core/contracts/runtime.py +19 -0
  31. core/contracts/vyrelon_runtime.py +118 -0
  32. core/contracts/work_unit.py +48 -0
  33. core/delegation.py +33 -0
  34. core/handoff.py +68 -0
  35. core/lifecycle.py +85 -0
  36. core/multi_agent_workflow.py +574 -0
  37. core/orchestrator.py +51 -0
  38. core/planning.py +22 -0
  39. core/registry.py +44 -0
  40. core/routing.py +195 -0
  41. core/state.py +155 -0
  42. installer/__init__.py +1 -0
  43. installer/init.py +71 -0
  44. integrations/__init__.py +0 -0
  45. integrations/github/__init__.py +3 -0
  46. integrations/github/gateway.py +164 -0
  47. integrations/openai/__init__.py +1 -0
  48. integrations/openai/chat_agent.py +147 -0
  49. multiagentos/__init__.py +1 -0
  50. multiagentos/cli.py +759 -0
  51. multiagentos-0.4.0.dist-info/METADATA +7 -0
  52. multiagentos-0.4.0.dist-info/RECORD +122 -0
  53. multiagentos-0.4.0.dist-info/WHEEL +5 -0
  54. multiagentos-0.4.0.dist-info/entry_points.txt +2 -0
  55. multiagentos-0.4.0.dist-info/top_level.txt +7 -0
  56. profiles/__init__.py +1 -0
  57. profiles/common.py +11 -0
  58. profiles/detector.py +67 -0
  59. profiles/registry.py +46 -0
  60. profiles/resolver.py +63 -0
  61. runtime/__init__.py +5 -0
  62. runtime/agent/__init__.py +1 -0
  63. runtime/agent/ide.py +74 -0
  64. runtime/agent/model.py +197 -0
  65. runtime/agent/model_backed.py +49 -0
  66. runtime/agent/process.py +21 -0
  67. runtime/builtin_tools.py +57 -0
  68. runtime/capability.py +143 -0
  69. runtime/chat_adapter_registry.py +43 -0
  70. runtime/chat_config.py +57 -0
  71. runtime/chat_execution.py +52 -0
  72. runtime/config/__init__.py +1 -0
  73. runtime/config/loader.py +67 -0
  74. runtime/discovery.py +334 -0
  75. runtime/execution_config.py +71 -0
  76. runtime/execution_registry.py +50 -0
  77. runtime/git.py +84 -0
  78. runtime/github.py +56 -0
  79. runtime/github_probe.py +23 -0
  80. runtime/health.py +14 -0
  81. runtime/ide/__init__.py +6 -0
  82. runtime/ide/bridge.py +198 -0
  83. runtime/ide/registry.py +24 -0
  84. runtime/ide/runtime.py +35 -0
  85. runtime/local/__init__.py +18 -0
  86. runtime/local/filesystem.py +58 -0
  87. runtime/local/patch.py +79 -0
  88. runtime/local/path_security.py +31 -0
  89. runtime/local/permissions.py +20 -0
  90. runtime/local/process.py +56 -0
  91. runtime/local/shell.py +97 -0
  92. runtime/mcp/__init__.py +7 -0
  93. runtime/mcp/client.py +120 -0
  94. runtime/mcp/config.py +36 -0
  95. runtime/mcp/policy.py +57 -0
  96. runtime/mcp/proxy.py +30 -0
  97. runtime/mcp/server.py +137 -0
  98. runtime/mcp/session.py +19 -0
  99. runtime/model/__init__.py +5 -0
  100. runtime/model/ai_runtime.py +165 -0
  101. runtime/model/cli.py +49 -0
  102. runtime/model/config.py +98 -0
  103. runtime/model/credentials.py +42 -0
  104. runtime/model/factory.py +82 -0
  105. runtime/model/http.py +78 -0
  106. runtime/model/invoker.py +16 -0
  107. runtime/model/materializer.py +101 -0
  108. runtime/model/native.py +244 -0
  109. runtime/model/providers.py +55 -0
  110. runtime/model/registry.py +26 -0
  111. runtime/model/runtime_loader.py +51 -0
  112. runtime/model_control.py +136 -0
  113. runtime/multi_agent.py +142 -0
  114. runtime/policy.py +28 -0
  115. runtime/process.py +41 -0
  116. runtime/quota.py +259 -0
  117. runtime/repository.py +103 -0
  118. runtime/repository_tools.py +62 -0
  119. runtime/status.py +87 -0
  120. runtime/tool_calling.py +134 -0
  121. runtime/validation.py +121 -0
  122. runtime/vyrelon.py +1557 -0
core/registry.py ADDED
@@ -0,0 +1,44 @@
1
+ """Registries for agents and AI providers."""
2
+
3
+ from core.contracts.agent import AgentContract
4
+ from core.contracts.ai import AIProvider, ModelSpec
5
+
6
+
7
+ class AgentRegistry:
8
+ def __init__(self) -> None:
9
+ self._agents: dict[str, AgentContract] = {}
10
+
11
+ def register(self, agent: AgentContract) -> None:
12
+ if agent.id in self._agents:
13
+ raise ValueError(f"Agent already registered: {agent.id}")
14
+ self._agents[agent.id] = agent
15
+
16
+ def get(self, agent_id: str) -> AgentContract:
17
+ return self._agents[agent_id]
18
+
19
+ def list(self) -> tuple[AgentContract, ...]:
20
+ return tuple(self._agents.values())
21
+
22
+
23
+ class AIRegistry:
24
+ def __init__(self) -> None:
25
+ self._providers: dict[str, AIProvider] = {}
26
+ self._models: dict[str, ModelSpec] = {}
27
+
28
+ def register(self, provider: AIProvider) -> None:
29
+ if provider.id in self._providers:
30
+ raise ValueError(f"Provider already registered: {provider.id}")
31
+ self._providers[provider.id] = provider
32
+ for model in provider.models:
33
+ if model.id in self._models:
34
+ raise ValueError(f"Model already registered: {model.id}")
35
+ self._models[model.id] = model
36
+
37
+ def provider(self, provider_id: str) -> AIProvider:
38
+ return self._providers[provider_id]
39
+
40
+ def model(self, model_id: str) -> ModelSpec:
41
+ return self._models[model_id]
42
+
43
+ def models(self) -> tuple[ModelSpec, ...]:
44
+ return tuple(self._models.values())
core/routing.py ADDED
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import TYPE_CHECKING
6
+
7
+ from core.contracts.agent import AgentContract
8
+ from core.contracts.ai import ModelSpec
9
+ from core.contracts.health import ModelHealth
10
+ from core.contracts.quota import QuotaSnapshot
11
+ if TYPE_CHECKING:
12
+ from runtime.capability import CapabilityRegistry
13
+
14
+
15
+ class RoutingStrategy(str, Enum):
16
+ EXPLICIT = "explicit"
17
+ POOL = "pool"
18
+ AUTO = "auto"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Assignment:
23
+ agent_id: str
24
+ model_id: str
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RoutingCandidate:
29
+ model_id: str
30
+ provider_id: str
31
+ selected: bool
32
+ compatible: bool
33
+ health_available: bool
34
+ quota_available: bool
35
+ capability_score: float
36
+ quota_score: float
37
+ missing_capabilities: frozenset[str]
38
+ rejection_reasons: tuple[str, ...]
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class RoutingExplanation:
43
+ agent_id: str
44
+ strategy: RoutingStrategy
45
+ selected_model_id: str | None
46
+ candidates: tuple[RoutingCandidate, ...]
47
+
48
+ @property
49
+ def assignment(self) -> Assignment:
50
+ if self.selected_model_id is None:
51
+ raise LookupError(f"No compatible available model for agent: {self.agent_id}")
52
+ return Assignment(self.agent_id, self.selected_model_id)
53
+
54
+
55
+ class AIRouter:
56
+ def assign(
57
+ self,
58
+ agent: AgentContract,
59
+ models: list[ModelSpec],
60
+ preferred_model_ids: list[str] | None = None,
61
+ strategy: RoutingStrategy | str = RoutingStrategy.POOL,
62
+ quota_snapshots: dict[str, QuotaSnapshot] | None = None,
63
+ health_snapshots: dict[str, ModelHealth] | None = None,
64
+ capability_registry: CapabilityRegistry | None = None,
65
+ ) -> Assignment:
66
+ return self.explain(
67
+ agent,
68
+ models,
69
+ preferred_model_ids=preferred_model_ids,
70
+ strategy=strategy,
71
+ quota_snapshots=quota_snapshots,
72
+ health_snapshots=health_snapshots,
73
+ capability_registry=capability_registry,
74
+ ).assignment
75
+
76
+ def explain(
77
+ self,
78
+ agent: AgentContract,
79
+ models: list[ModelSpec],
80
+ preferred_model_ids: list[str] | None = None,
81
+ strategy: RoutingStrategy | str = RoutingStrategy.POOL,
82
+ quota_snapshots: dict[str, QuotaSnapshot] | None = None,
83
+ health_snapshots: dict[str, ModelHealth] | None = None,
84
+ capability_registry: CapabilityRegistry | None = None,
85
+ ) -> RoutingExplanation:
86
+ """Explain model eligibility, rejection reasons, and the selected model."""
87
+ from runtime.quota import quota_available, quota_score
88
+
89
+ strategy = RoutingStrategy(strategy)
90
+ preferred = preferred_model_ids if preferred_model_ids is not None else list(agent.model_ids)
91
+ preferred_set = set(preferred)
92
+ quota_snapshots = quota_snapshots or {}
93
+ health_snapshots = health_snapshots or {}
94
+ requirements = frozenset(agent.capabilities)
95
+
96
+ def capability_match(model: ModelSpec):
97
+ if capability_registry is None:
98
+ matched = requirements & model.capabilities
99
+ return matched, requirements - model.capabilities, (
100
+ len(matched) / len(requirements) if requirements else 1.0
101
+ )
102
+ match = capability_registry.match(model, requirements)
103
+ return match.matched, match.missing, match.score
104
+
105
+ candidates = []
106
+ for model in models:
107
+ _, missing, cap_score = capability_match(model)
108
+ health = health_snapshots.get(model.id)
109
+ health_ok = health is None or health.available
110
+ quota = quota_snapshots.get(model.id)
111
+ quota_ok = quota is None or quota_available(quota)
112
+ reasons = []
113
+ if missing:
114
+ reasons.append("missing_capabilities")
115
+ if not health_ok:
116
+ reasons.append("unhealthy")
117
+ if not quota_ok:
118
+ reasons.append("quota_unavailable")
119
+ if strategy is RoutingStrategy.EXPLICIT and model.id not in preferred_set:
120
+ reasons.append("not_explicitly_selected")
121
+ candidates.append(
122
+ RoutingCandidate(
123
+ model_id=model.id,
124
+ provider_id=model.provider_id,
125
+ selected=False,
126
+ compatible=not missing,
127
+ health_available=health_ok,
128
+ quota_available=quota_ok,
129
+ capability_score=cap_score,
130
+ quota_score=quota_score(quota) if quota is not None else 0.5,
131
+ missing_capabilities=frozenset(missing),
132
+ rejection_reasons=tuple(reasons),
133
+ )
134
+ )
135
+
136
+ by_id = {model.id: model for model in models}
137
+ selected_model_id = None
138
+
139
+ if strategy in {RoutingStrategy.EXPLICIT, RoutingStrategy.POOL}:
140
+ for model_id in preferred:
141
+ model = by_id.get(model_id)
142
+ if model is None:
143
+ continue
144
+ candidate = next(item for item in candidates if item.model_id == model.id)
145
+ if candidate.compatible and candidate.health_available and candidate.quota_available:
146
+ selected_model_id = model.id
147
+ break
148
+ if selected_model_id is None and strategy is RoutingStrategy.EXPLICIT:
149
+ raise LookupError(f"No explicitly assigned available model for agent: {agent.id}")
150
+
151
+ if selected_model_id is None and strategy in {RoutingStrategy.POOL, RoutingStrategy.AUTO}:
152
+ eligible = [
153
+ item for item in candidates
154
+ if item.compatible and item.health_available and item.quota_available
155
+ ]
156
+ eligible.sort(
157
+ key=lambda item: (item.capability_score, item.quota_score),
158
+ reverse=True,
159
+ )
160
+ if eligible:
161
+ selected_model_id = eligible[0].model_id
162
+
163
+ if selected_model_id is None:
164
+ raise LookupError(f"No compatible available model for agent: {agent.id}")
165
+
166
+ explained = tuple(
167
+ RoutingCandidate(
168
+ model_id=item.model_id,
169
+ provider_id=item.provider_id,
170
+ selected=item.model_id == selected_model_id,
171
+ compatible=item.compatible,
172
+ health_available=item.health_available,
173
+ quota_available=item.quota_available,
174
+ capability_score=item.capability_score,
175
+ quota_score=item.quota_score,
176
+ missing_capabilities=item.missing_capabilities,
177
+ rejection_reasons=tuple(
178
+ reason for reason in item.rejection_reasons
179
+ if reason != "not_explicitly_selected" or item.model_id != selected_model_id
180
+ ),
181
+ )
182
+ for item in candidates
183
+ )
184
+ return RoutingExplanation(
185
+ agent_id=agent.id,
186
+ strategy=strategy,
187
+ selected_model_id=selected_model_id,
188
+ candidates=explained,
189
+ )
190
+
191
+ @staticmethod
192
+ def compatible(agent: AgentContract, model: ModelSpec) -> bool:
193
+ return not agent.capabilities or agent.capabilities.issubset(model.capabilities)
194
+
195
+ _compatible = compatible
core/state.py ADDED
@@ -0,0 +1,155 @@
1
+ """Persistent WorkUnit state store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from core.contracts.checkpoint import WorkflowCheckpoint
9
+ from core.contracts.work_unit import WorkStatus, WorkUnit
10
+ from core.contracts.vyrelon_runtime import SessionSpec, SessionState
11
+
12
+
13
+ class WorkStateStore:
14
+ def __init__(self, root: Path) -> None:
15
+ self.root = root
16
+ self.root.mkdir(parents=True, exist_ok=True)
17
+ self.checkpoint_root = self.root.parent / "checkpoints"
18
+ self.checkpoint_root.mkdir(parents=True, exist_ok=True)
19
+
20
+ def save(self, work_unit: WorkUnit) -> Path:
21
+ path = self.root / f"{work_unit.id}.json"
22
+ payload = {
23
+ "id": work_unit.id,
24
+ "objective": work_unit.objective,
25
+ "status": work_unit.status.value,
26
+ "inputs": work_unit.inputs,
27
+ "artifacts": work_unit.artifacts,
28
+ "assigned_agents": work_unit.assigned_agents,
29
+ "metadata": work_unit.metadata,
30
+ }
31
+ path.write_text(
32
+ json.dumps(payload, indent=2, default=str) + "\n",
33
+ encoding="utf-8",
34
+ )
35
+ return path
36
+
37
+ def exists(self, work_unit_id: str) -> bool:
38
+ return (self.root / f"{work_unit_id}.json").exists()
39
+
40
+ def list_ids(self) -> tuple[str, ...]:
41
+ return tuple(sorted(path.stem for path in self.root.glob("*.json")))
42
+
43
+ def load(self, work_unit_id: str) -> WorkUnit:
44
+ path = self.root / f"{work_unit_id}.json"
45
+ data = json.loads(path.read_text(encoding="utf-8"))
46
+ return WorkUnit(
47
+ id=data["id"],
48
+ objective=data["objective"],
49
+ status=WorkStatus(data["status"]),
50
+ inputs=dict(data.get("inputs", {})),
51
+ artifacts=list(data.get("artifacts", [])),
52
+ assigned_agents=list(data.get("assigned_agents", [])),
53
+ metadata=dict(data.get("metadata", {})),
54
+ )
55
+
56
+ def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> Path:
57
+ """Persist a durable checkpoint independently of transient model output."""
58
+ checkpoint.validate()
59
+ path = self.checkpoint_root / f"{checkpoint.work_unit_id}.json"
60
+ path.write_text(
61
+ json.dumps(checkpoint.to_dict(), indent=2, ensure_ascii=False) + "\n",
62
+ encoding="utf-8",
63
+ )
64
+ return path
65
+
66
+
67
+ def load_checkpoint(self, work_unit_id: str) -> WorkflowCheckpoint:
68
+ """Load the latest durable checkpoint for a WorkUnit."""
69
+ path = self.checkpoint_root / f"{work_unit_id}.json"
70
+ data = json.loads(path.read_text(encoding="utf-8"))
71
+ return WorkflowCheckpoint.from_dict(data)
72
+
73
+
74
+ def checkpoint(
75
+ self,
76
+ work_unit: WorkUnit,
77
+ *,
78
+ workflow: str,
79
+ stage: str,
80
+ sequence: int = 0,
81
+ next_action: str | None = None,
82
+ agent_ids: tuple[str, ...] = (),
83
+ model_ids: tuple[str, ...] = (),
84
+ resumable: bool = True,
85
+ metadata: dict[str, object] | None = None,
86
+ ) -> WorkflowCheckpoint:
87
+ """Persist WorkUnit state and a normalized checkpoint atomically at the API level."""
88
+ self.save(work_unit)
89
+ checkpoint = WorkflowCheckpoint(
90
+ schema_version=1,
91
+ work_unit_id=work_unit.id,
92
+ workflow=workflow,
93
+ status=work_unit.status.value,
94
+ stage=stage,
95
+ sequence=sequence,
96
+ next_action=next_action,
97
+ agent_ids=agent_ids,
98
+ model_ids=model_ids,
99
+ artifact_ids=tuple(work_unit.artifacts),
100
+ findings=tuple(
101
+ str(value) for value in work_unit.metadata.get("findings", ())
102
+ ),
103
+ resumable=resumable,
104
+ metadata=dict(metadata or {}),
105
+ )
106
+ self.save_checkpoint(checkpoint)
107
+ return checkpoint
108
+
109
+
110
+
111
+ class SessionStateStore:
112
+ """Persist SessionState independently from WorkUnit state."""
113
+
114
+ def __init__(self, root: Path) -> None:
115
+ self.root = root
116
+ self.root.mkdir(parents=True, exist_ok=True)
117
+
118
+ def save(self, state: SessionState) -> Path:
119
+ path = self.root / f"{state.spec.id}.json"
120
+ payload = {
121
+ "spec": {
122
+ "id": state.spec.id,
123
+ "project_root": state.spec.project_root,
124
+ "agent_id": state.spec.agent_id,
125
+ "model_id": state.spec.model_id,
126
+ "checkpoint_id": state.spec.checkpoint_id,
127
+ "metadata": state.spec.metadata,
128
+ },
129
+ "work_unit_ids": state.work_unit_ids,
130
+ "status": state.status,
131
+ "metadata": state.metadata,
132
+ }
133
+ path.write_text(json.dumps(payload, indent=2, default=str) + "\n", encoding="utf-8")
134
+ return path
135
+
136
+ def exists(self, session_id: str) -> bool:
137
+ return (self.root / f"{session_id}.json").exists()
138
+
139
+ def load(self, session_id: str) -> SessionState:
140
+ data = json.loads((self.root / f"{session_id}.json").read_text(encoding="utf-8"))
141
+ raw = data["spec"]
142
+ spec = SessionSpec(
143
+ id=raw["id"],
144
+ project_root=raw["project_root"],
145
+ agent_id=raw.get("agent_id"),
146
+ model_id=raw.get("model_id"),
147
+ checkpoint_id=raw.get("checkpoint_id"),
148
+ metadata=dict(raw.get("metadata", {})),
149
+ )
150
+ return SessionState(
151
+ spec=spec,
152
+ work_unit_ids=list(data.get("work_unit_ids", [])),
153
+ status=data.get("status", "active"),
154
+ metadata=dict(data.get("metadata", {})),
155
+ )
installer/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """VYRELON project installer."""
installer/init.py ADDED
@@ -0,0 +1,71 @@
1
+ """Apply VYRELON project bootstrap files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from agents.catalog import build_agent_catalog
9
+ from core.contracts.profile import DetectionResult
10
+ from runtime.execution_config import write_default_execution_config
11
+ from runtime.chat_config import write_default_chat_config
12
+
13
+
14
+ class ProjectInitializer:
15
+ COMPONENTS = frozenset({"vyrelon", "multi-agent", "all"})
16
+
17
+ def apply(
18
+ self,
19
+ project_root: Path,
20
+ detections: tuple[DetectionResult, ...],
21
+ component: str = "all",
22
+ ) -> Path:
23
+ if component not in self.COMPONENTS:
24
+ raise ValueError(f"unsupported component: {component}")
25
+ target = project_root / ".multiagentos"
26
+ target.mkdir(parents=True, exist_ok=True)
27
+ payload = {
28
+ "version": 1,
29
+ "profiles": [
30
+ {
31
+ "id": result.profile_id,
32
+ "confidence": result.confidence,
33
+ "evidence": list(result.evidence),
34
+ }
35
+ for result in detections
36
+ ],
37
+ }
38
+ config = target / "profile.json"
39
+ config.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
40
+ (target / "components.json").write_text(
41
+ json.dumps({"version": 1, "components": [component]}, indent=2) + "\n",
42
+ encoding="utf-8",
43
+ )
44
+
45
+ if component in {"vyrelon", "all"}:
46
+ write_default_execution_config(project_root)
47
+ write_default_chat_config(project_root)
48
+
49
+ if component == "vyrelon":
50
+ return config
51
+
52
+ agents = build_agent_catalog(tuple(result.profile_id for result in detections))
53
+ agent_payload = {
54
+ "version": 1,
55
+ "agents": [
56
+ {
57
+ "id": agent.id,
58
+ "role": agent.role,
59
+ "kind": agent.kind,
60
+ "capabilities": sorted(agent.capabilities),
61
+ "tools": sorted(agent.tools),
62
+ "permissions": sorted(agent.permissions),
63
+ "models": list(agent.model_ids),
64
+ }
65
+ for agent in agents
66
+ ],
67
+ }
68
+ (target / "agents.json").write_text(
69
+ json.dumps(agent_payload, indent=2) + "\n", encoding="utf-8"
70
+ )
71
+ return config
File without changes
@@ -0,0 +1,3 @@
1
+ from .gateway import GitHubGatewayClient
2
+
3
+ __all__ = ["GitHubGatewayClient"]
@@ -0,0 +1,164 @@
1
+ """GitHub adapter using the locally authenticated GitHub CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import subprocess
8
+ from typing import Any
9
+
10
+ from core.contracts.github import (
11
+ GitHubBranch,
12
+ GitHubFile,
13
+ GitHubGateway,
14
+ GitHubIssue,
15
+ GitHubRepository,
16
+ MergeResult,
17
+ PullRequest,
18
+ ReviewResult,
19
+ WorkflowRun,
20
+ )
21
+
22
+
23
+ class GitHubGatewayClient:
24
+ def __init__(self, gh_binary: str = "gh") -> None:
25
+ self.gh_binary = gh_binary
26
+
27
+ def _run(self, *args: str) -> Any:
28
+ result = subprocess.run(
29
+ [self.gh_binary, *args],
30
+ check=False,
31
+ capture_output=True,
32
+ text=True,
33
+ )
34
+ if result.returncode != 0:
35
+ raise RuntimeError(
36
+ f"gh command failed ({result.returncode}): "
37
+ f"{' '.join(args)}\n{result.stderr.strip()}"
38
+ )
39
+ output = result.stdout.strip()
40
+ return json.loads(output) if output else None
41
+
42
+ def get_repository(self, full_name: str) -> GitHubRepository:
43
+ data = self._run(
44
+ "repo", "view", full_name,
45
+ "--json", "nameWithOwner,defaultBranchRef,isPrivate",
46
+ )
47
+ return GitHubRepository(
48
+ full_name=data["nameWithOwner"],
49
+ default_branch=data["defaultBranchRef"]["name"],
50
+ private=data["isPrivate"],
51
+ )
52
+
53
+ def get_branch(self, full_name: str, branch: str) -> GitHubBranch:
54
+ data = self._run(
55
+ "api", f"repos/{full_name}/branches/{branch}",
56
+ "--jq", "{name: .name, sha: .commit.sha}",
57
+ )
58
+ return GitHubBranch(name=data["name"], sha=data["sha"])
59
+
60
+ def create_branch(self, full_name: str, branch: str, base: str) -> GitHubBranch:
61
+ base_ref = self.get_branch(full_name, base)
62
+ self._run(
63
+ "api", f"repos/{full_name}/git/refs", "--method", "POST",
64
+ "-f", f"ref=refs/heads/{branch}", "-f", f"sha={base_ref.sha}",
65
+ )
66
+ return GitHubBranch(name=branch, sha=base_ref.sha)
67
+
68
+ def get_file(self, full_name: str, path: str, ref: str) -> GitHubFile:
69
+ data = self._run(
70
+ "api", f"repos/{full_name}/contents/{path}",
71
+ "-f", f"ref={ref}",
72
+ )
73
+ content = base64.b64decode(data["content"]).decode("utf-8")
74
+ return GitHubFile(path=data["path"], content=content, sha=data["sha"])
75
+
76
+ def create_file(
77
+ self, full_name: str, path: str, content: str,
78
+ branch: str, message: str,
79
+ ) -> str:
80
+ encoded = base64.b64encode(content.encode()).decode()
81
+ data = self._run(
82
+ "api", f"repos/{full_name}/contents/{path}", "--method", "PUT",
83
+ "-f", f"message={message}", "-f", f"content={encoded}",
84
+ "-f", f"branch={branch}",
85
+ )
86
+ return data["commit"]["sha"]
87
+
88
+ def update_file(
89
+ self, full_name: str, path: str, content: str, sha: str,
90
+ branch: str, message: str,
91
+ ) -> str:
92
+ encoded = base64.b64encode(content.encode()).decode()
93
+ data = self._run(
94
+ "api", f"repos/{full_name}/contents/{path}", "--method", "PUT",
95
+ "-f", f"message={message}", "-f", f"content={encoded}",
96
+ "-f", f"sha={sha}", "-f", f"branch={branch}",
97
+ )
98
+ return data["commit"]["sha"]
99
+
100
+ def create_issue(self, full_name: str, title: str, body: str = "") -> GitHubIssue:
101
+ data = self._run(
102
+ "api", f"repos/{full_name}/issues", "--method", "POST",
103
+ "-f", f"title={title}", "-f", f"body={body}",
104
+ )
105
+ return GitHubIssue(number=data["number"], title=data["title"], url=data["html_url"])
106
+
107
+ def create_pull_request(
108
+ self, full_name: str, title: str, body: str,
109
+ head: str, base: str, draft: bool = False,
110
+ ) -> PullRequest:
111
+ args = [
112
+ "pr", "create", "--repo", full_name,
113
+ "--title", title, "--body", body,
114
+ "--head", head, "--base", base,
115
+ ]
116
+ if draft:
117
+ args.append("--draft")
118
+ data = self._run(*args, "--json", "number,title,url,headRefName,baseRefName")
119
+ return PullRequest(
120
+ number=data["number"],
121
+ title=data["title"],
122
+ url=data["url"],
123
+ head=data["headRefName"],
124
+ base=data["baseRefName"],
125
+ )
126
+
127
+ def review_pull_request(
128
+ self, full_name: str, number: int, action: str, body: str = "",
129
+ ) -> ReviewResult:
130
+ flags = {"approve": "--approve", "comment": "--comment", "request_changes": "--request-changes"}
131
+ try:
132
+ flag = flags[action]
133
+ except KeyError as exc:
134
+ raise ValueError(f"Unsupported review action: {action}") from exc
135
+ args = ["pr", "review", str(number), "--repo", full_name, flag]
136
+ if body:
137
+ args.extend(["--body", body])
138
+ self._run(*args)
139
+ return ReviewResult(success=True, action=action)
140
+
141
+ def merge_pull_request(
142
+ self, full_name: str, number: int, method: str = "squash",
143
+ ) -> MergeResult:
144
+ if method not in {"merge", "squash", "rebase"}:
145
+ raise ValueError(f"Unsupported merge method: {method}")
146
+ data = self._run(
147
+ "api", f"repos/{full_name}/pulls/{number}/merge",
148
+ "--method", "PUT", "-f", f"merge_method={method}",
149
+ )
150
+ return MergeResult(
151
+ merged=bool(data.get("merged")),
152
+ sha=data.get("sha"),
153
+ message=data.get("message", ""),
154
+ )
155
+
156
+ def list_workflows(self, full_name: str, ref: str) -> list[WorkflowRun]:
157
+ data = self._run(
158
+ "run", "list", "--repo", full_name, "--branch", ref,
159
+ "--json", "databaseId,status,conclusion",
160
+ )
161
+ return [
162
+ WorkflowRun(item["databaseId"], item["status"], item["conclusion"])
163
+ for item in data
164
+ ]
@@ -0,0 +1 @@
1
+ """OpenAI provider adapters for VYRELON Chat Agents."""