gdmcode 0.1.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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""GitHub Actions integration — post review comments on PRs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
_GH_API = "https://api.github.com"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class PRReviewComment:
|
|
15
|
+
body: str
|
|
16
|
+
path: str | None = None
|
|
17
|
+
line: int | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GitHubActionsClient:
|
|
21
|
+
"""Post review comments to GitHub PRs using the GH REST API."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, token: str | None = None, repo: str | None = None):
|
|
24
|
+
self._token = token or os.environ.get("GITHUB_TOKEN", "")
|
|
25
|
+
self._repo = repo or os.environ.get("GITHUB_REPOSITORY", "")
|
|
26
|
+
|
|
27
|
+
# ------------------------------------------------------------------
|
|
28
|
+
# Public API
|
|
29
|
+
# ------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def post_pr_comment(self, pr_number: int, body: str) -> bool:
|
|
32
|
+
"""Post a general comment on a PR. Returns True on success."""
|
|
33
|
+
if not self._token or not self._repo:
|
|
34
|
+
return False
|
|
35
|
+
url = f"{_GH_API}/repos/{self._repo}/issues/{pr_number}/comments"
|
|
36
|
+
payload = json.dumps({"body": body}).encode()
|
|
37
|
+
req = self._build_request(url, data=payload, method="POST")
|
|
38
|
+
return self._send(req)
|
|
39
|
+
|
|
40
|
+
def post_review(
|
|
41
|
+
self,
|
|
42
|
+
pr_number: int,
|
|
43
|
+
comments: list[PRReviewComment],
|
|
44
|
+
summary: str,
|
|
45
|
+
) -> bool:
|
|
46
|
+
"""Post a full review with inline comments."""
|
|
47
|
+
if not self._token or not self._repo:
|
|
48
|
+
return False
|
|
49
|
+
url = f"{_GH_API}/repos/{self._repo}/pulls/{pr_number}/reviews"
|
|
50
|
+
inline = []
|
|
51
|
+
for c in comments:
|
|
52
|
+
item: dict[str, object] = {"body": c.body}
|
|
53
|
+
if c.path is not None:
|
|
54
|
+
item["path"] = c.path
|
|
55
|
+
if c.line is not None:
|
|
56
|
+
item["line"] = c.line
|
|
57
|
+
inline.append(item)
|
|
58
|
+
payload = json.dumps(
|
|
59
|
+
{"body": summary, "event": "COMMENT", "comments": inline}
|
|
60
|
+
).encode()
|
|
61
|
+
req = self._build_request(url, data=payload, method="POST")
|
|
62
|
+
return self._send(req)
|
|
63
|
+
|
|
64
|
+
def get_pr_diff(self, pr_number: int) -> str:
|
|
65
|
+
"""Fetch the unified diff for a PR."""
|
|
66
|
+
if not self._token or not self._repo:
|
|
67
|
+
return ""
|
|
68
|
+
url = f"{_GH_API}/repos/{self._repo}/pulls/{pr_number}"
|
|
69
|
+
req = self._build_request(url)
|
|
70
|
+
req.add_header("Accept", "application/vnd.github.diff")
|
|
71
|
+
try:
|
|
72
|
+
with urllib.request.urlopen(req) as resp:
|
|
73
|
+
return resp.read().decode("utf-8", errors="replace")
|
|
74
|
+
except (urllib.error.HTTPError, urllib.error.URLError, OSError):
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def is_ci_environment() -> bool:
|
|
79
|
+
"""Return True when running inside GitHub Actions."""
|
|
80
|
+
return os.environ.get("GITHUB_ACTIONS") == "true"
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Helpers
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _build_request(
|
|
87
|
+
self,
|
|
88
|
+
url: str,
|
|
89
|
+
*,
|
|
90
|
+
data: bytes | None = None,
|
|
91
|
+
method: str = "GET",
|
|
92
|
+
) -> urllib.request.Request:
|
|
93
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
94
|
+
req.add_header("Authorization", f"Bearer {self._token}")
|
|
95
|
+
req.add_header("Accept", "application/vnd.github+json")
|
|
96
|
+
req.add_header("X-GitHub-Api-Version", "2022-11-28")
|
|
97
|
+
if data is not None:
|
|
98
|
+
req.add_header("Content-Type", "application/json")
|
|
99
|
+
return req
|
|
100
|
+
|
|
101
|
+
def _send(self, req: urllib.request.Request) -> bool:
|
|
102
|
+
try:
|
|
103
|
+
with urllib.request.urlopen(req) as resp:
|
|
104
|
+
return resp.status in (200, 201)
|
|
105
|
+
except (urllib.error.HTTPError, urllib.error.URLError, OSError):
|
|
106
|
+
return False
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""MCP server -- exposes gdm tools via Model Context Protocol (stdio transport).
|
|
2
|
+
|
|
3
|
+
Protocol reference: https://spec.modelcontextprotocol.io/
|
|
4
|
+
Transport: newline-delimited JSON-RPC 2.0 over stdin/stdout.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
__all__ = ["MCPServer", "MCPTool", "MCPResource"]
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Data structures
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class MCPTool:
|
|
21
|
+
name: str
|
|
22
|
+
description: str
|
|
23
|
+
input_schema: dict
|
|
24
|
+
handler: Callable[..., Any]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class MCPResource:
|
|
29
|
+
uri: str
|
|
30
|
+
name: str
|
|
31
|
+
description: str
|
|
32
|
+
mime_type: str = "text/plain"
|
|
33
|
+
reader: Callable[[], str] = field(default_factory=lambda: (lambda: ""))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# JSON-RPC error codes (MCP-compatible subset)
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
_PARSE_ERROR = -32700
|
|
41
|
+
_INVALID_REQUEST = -32600
|
|
42
|
+
_METHOD_NOT_FOUND = -32601
|
|
43
|
+
_INVALID_PARAMS = -32602
|
|
44
|
+
_INTERNAL_ERROR = -32603
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# MCPServer
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
class MCPServer:
|
|
52
|
+
"""Minimal MCP server with stdio transport."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, name: str = "gdm-code", version: str = "0.1.0") -> None:
|
|
55
|
+
self._name = name
|
|
56
|
+
self._version = version
|
|
57
|
+
self._tools: dict[str, MCPTool] = {}
|
|
58
|
+
self._resources: dict[str, MCPResource] = {}
|
|
59
|
+
|
|
60
|
+
def register_tool(self, tool: MCPTool) -> None:
|
|
61
|
+
self._tools[tool.name] = tool
|
|
62
|
+
|
|
63
|
+
def register_resource(self, resource: MCPResource) -> None:
|
|
64
|
+
self._resources[resource.uri] = resource
|
|
65
|
+
|
|
66
|
+
def handle_message(self, msg: dict) -> dict | None:
|
|
67
|
+
"""Route a JSON-RPC message and return a response dict.
|
|
68
|
+
|
|
69
|
+
Returns None for notifications (messages without an id field).
|
|
70
|
+
"""
|
|
71
|
+
msg_id = msg.get("id")
|
|
72
|
+
method = msg.get("method")
|
|
73
|
+
|
|
74
|
+
if not method:
|
|
75
|
+
if msg_id is None:
|
|
76
|
+
return None
|
|
77
|
+
return self._make_error(msg_id, _INVALID_REQUEST, "Missing 'method'")
|
|
78
|
+
|
|
79
|
+
is_notification = msg_id is None
|
|
80
|
+
params = msg.get("params") or {}
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
result = self._dispatch(method, params)
|
|
84
|
+
except _MCPError as exc:
|
|
85
|
+
if is_notification:
|
|
86
|
+
return None
|
|
87
|
+
return self._make_error(msg_id, exc.code, exc.message)
|
|
88
|
+
except Exception as exc: # noqa: BLE001
|
|
89
|
+
if is_notification:
|
|
90
|
+
return None
|
|
91
|
+
return self._make_error(msg_id, _INTERNAL_ERROR, str(exc))
|
|
92
|
+
|
|
93
|
+
if is_notification:
|
|
94
|
+
return None
|
|
95
|
+
return self._make_result(msg_id, result)
|
|
96
|
+
|
|
97
|
+
def _dispatch(self, method: str, params: dict) -> Any:
|
|
98
|
+
match method:
|
|
99
|
+
case "initialize":
|
|
100
|
+
return self._handle_initialize(params)
|
|
101
|
+
case "tools/list":
|
|
102
|
+
return self._handle_tools_list()
|
|
103
|
+
case "tools/call":
|
|
104
|
+
return self._handle_tools_call(params)
|
|
105
|
+
case "resources/list":
|
|
106
|
+
return self._handle_resources_list()
|
|
107
|
+
case "resources/read":
|
|
108
|
+
return self._handle_resources_read(params)
|
|
109
|
+
case _:
|
|
110
|
+
raise _MCPError(_METHOD_NOT_FOUND, f"Method not found: {method!r}")
|
|
111
|
+
|
|
112
|
+
def _handle_initialize(self, params: dict) -> dict:
|
|
113
|
+
return {
|
|
114
|
+
"protocolVersion": "2024-11-05",
|
|
115
|
+
"serverInfo": {"name": self._name, "version": self._version},
|
|
116
|
+
"capabilities": {"tools": {}, "resources": {}},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def _handle_tools_list(self) -> dict:
|
|
120
|
+
return {
|
|
121
|
+
"tools": [
|
|
122
|
+
{"name": t.name, "description": t.description, "inputSchema": t.input_schema}
|
|
123
|
+
for t in self._tools.values()
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
def _handle_tools_call(self, params: dict) -> dict:
|
|
128
|
+
tool_name = params.get("name")
|
|
129
|
+
if not tool_name:
|
|
130
|
+
raise _MCPError(_INVALID_PARAMS, "Missing 'name' in tools/call params")
|
|
131
|
+
tool = self._tools.get(tool_name)
|
|
132
|
+
if tool is None:
|
|
133
|
+
raise _MCPError(_METHOD_NOT_FOUND, f"Unknown tool: {tool_name!r}")
|
|
134
|
+
arguments = params.get("arguments") or {}
|
|
135
|
+
try:
|
|
136
|
+
result = tool.handler(**arguments)
|
|
137
|
+
except Exception as exc: # noqa: BLE001
|
|
138
|
+
return {"isError": True, "content": [{"type": "text", "text": str(exc)}]}
|
|
139
|
+
text = result if isinstance(result, str) else json.dumps(result)
|
|
140
|
+
return {"isError": False, "content": [{"type": "text", "text": text}]}
|
|
141
|
+
|
|
142
|
+
def _handle_resources_list(self) -> dict:
|
|
143
|
+
return {
|
|
144
|
+
"resources": [
|
|
145
|
+
{"uri": r.uri, "name": r.name, "description": r.description, "mimeType": r.mime_type}
|
|
146
|
+
for r in self._resources.values()
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
def _handle_resources_read(self, params: dict) -> dict:
|
|
151
|
+
uri = params.get("uri")
|
|
152
|
+
if not uri:
|
|
153
|
+
raise _MCPError(_INVALID_PARAMS, "Missing 'uri' in resources/read params")
|
|
154
|
+
resource = self._resources.get(uri)
|
|
155
|
+
if resource is None:
|
|
156
|
+
raise _MCPError(_METHOD_NOT_FOUND, f"Unknown resource URI: {uri!r}")
|
|
157
|
+
try:
|
|
158
|
+
content = resource.reader()
|
|
159
|
+
except Exception as exc: # noqa: BLE001
|
|
160
|
+
raise _MCPError(_INTERNAL_ERROR, f"Resource read error: {exc}") from exc
|
|
161
|
+
return {"contents": [{"uri": uri, "mimeType": resource.mime_type, "text": content}]}
|
|
162
|
+
|
|
163
|
+
def run(self) -> None:
|
|
164
|
+
"""Read newline-delimited JSON from stdin; write responses to stdout."""
|
|
165
|
+
for raw_line in sys.stdin:
|
|
166
|
+
raw_line = raw_line.strip()
|
|
167
|
+
if not raw_line:
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
msg = json.loads(raw_line)
|
|
171
|
+
except json.JSONDecodeError as exc:
|
|
172
|
+
resp = self._make_error(None, _PARSE_ERROR, f"Parse error: {exc}")
|
|
173
|
+
self._write(resp)
|
|
174
|
+
continue
|
|
175
|
+
response = self.handle_message(msg)
|
|
176
|
+
if response is not None:
|
|
177
|
+
self._write(response)
|
|
178
|
+
|
|
179
|
+
def _write(self, obj: dict) -> None:
|
|
180
|
+
sys.stdout.write(json.dumps(obj) + "\n")
|
|
181
|
+
sys.stdout.flush()
|
|
182
|
+
|
|
183
|
+
def _make_error(self, id: Any, code: int, message: str) -> dict:
|
|
184
|
+
return {"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}
|
|
185
|
+
|
|
186
|
+
def _make_result(self, id: Any, result: Any) -> dict:
|
|
187
|
+
return {"jsonrpc": "2.0", "id": id, "result": result}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Internal exception for structured error propagation
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
class _MCPError(Exception):
|
|
195
|
+
def __init__(self, code: int, message: str) -> None:
|
|
196
|
+
super().__init__(message)
|
|
197
|
+
self.code = code
|
|
198
|
+
self.message = message
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Default gdm tool handlers
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def _make_read_file_handler() -> Callable[[str], str]:
|
|
206
|
+
from pathlib import Path
|
|
207
|
+
|
|
208
|
+
def _read_file(path: str) -> str:
|
|
209
|
+
p = Path(path)
|
|
210
|
+
if not p.exists():
|
|
211
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
212
|
+
return p.read_text(encoding="utf-8", errors="replace")
|
|
213
|
+
|
|
214
|
+
return _read_file
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _make_write_file_handler() -> Callable[[str, str], str]:
|
|
218
|
+
from pathlib import Path
|
|
219
|
+
|
|
220
|
+
def _write_file(path: str, content: str) -> str:
|
|
221
|
+
p = Path(path)
|
|
222
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
p.write_text(content, encoding="utf-8")
|
|
224
|
+
return f"Written {len(content)} bytes to {path}"
|
|
225
|
+
|
|
226
|
+
return _write_file
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _make_run_shell_handler() -> Callable[[str], str]:
|
|
230
|
+
import subprocess
|
|
231
|
+
|
|
232
|
+
def _run_shell(command: str) -> str:
|
|
233
|
+
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=60)
|
|
234
|
+
out = result.stdout
|
|
235
|
+
if result.stderr:
|
|
236
|
+
out += "\n[stderr]\n" + result.stderr
|
|
237
|
+
return out or f"(exit code {result.returncode})"
|
|
238
|
+
|
|
239
|
+
return _run_shell
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _make_search_code_handler() -> Callable[[str, str], str]:
|
|
243
|
+
import subprocess
|
|
244
|
+
from pathlib import Path
|
|
245
|
+
|
|
246
|
+
def _search_code(pattern: str, path: str = ".") -> str:
|
|
247
|
+
try:
|
|
248
|
+
result = subprocess.run(
|
|
249
|
+
["rg", "--line-number", "--no-heading", pattern, path],
|
|
250
|
+
capture_output=True, text=True, timeout=30,
|
|
251
|
+
)
|
|
252
|
+
return result.stdout or "(no matches)"
|
|
253
|
+
except FileNotFoundError:
|
|
254
|
+
import re
|
|
255
|
+
matches: list[str] = []
|
|
256
|
+
root = Path(path)
|
|
257
|
+
try:
|
|
258
|
+
rx = re.compile(pattern)
|
|
259
|
+
except re.error as exc:
|
|
260
|
+
return f"Invalid pattern: {exc}"
|
|
261
|
+
for fp in root.rglob("*"):
|
|
262
|
+
if not fp.is_file():
|
|
263
|
+
continue
|
|
264
|
+
try:
|
|
265
|
+
for i, line in enumerate(fp.read_text(encoding="utf-8", errors="ignore").splitlines(), 1):
|
|
266
|
+
if rx.search(line):
|
|
267
|
+
matches.append(f"{fp}:{i}:{line}")
|
|
268
|
+
if len(matches) >= 500:
|
|
269
|
+
break
|
|
270
|
+
except OSError:
|
|
271
|
+
continue
|
|
272
|
+
if len(matches) >= 500:
|
|
273
|
+
break
|
|
274
|
+
return "\n".join(matches) or "(no matches)"
|
|
275
|
+
|
|
276
|
+
return _search_code
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def build_default_server() -> MCPServer:
|
|
280
|
+
"""Return an MCPServer pre-loaded with the standard gdm tools."""
|
|
281
|
+
server = MCPServer()
|
|
282
|
+
|
|
283
|
+
server.register_tool(MCPTool(
|
|
284
|
+
name="read_file",
|
|
285
|
+
description="Read the contents of a file from the project.",
|
|
286
|
+
input_schema={
|
|
287
|
+
"type": "object",
|
|
288
|
+
"properties": {"path": {"type": "string", "description": "Path to the file to read."}},
|
|
289
|
+
"required": ["path"],
|
|
290
|
+
},
|
|
291
|
+
handler=_make_read_file_handler(),
|
|
292
|
+
))
|
|
293
|
+
|
|
294
|
+
server.register_tool(MCPTool(
|
|
295
|
+
name="write_file",
|
|
296
|
+
description="Write content to a file (creates or overwrites).",
|
|
297
|
+
input_schema={
|
|
298
|
+
"type": "object",
|
|
299
|
+
"properties": {
|
|
300
|
+
"path": {"type": "string", "description": "Destination file path."},
|
|
301
|
+
"content": {"type": "string", "description": "File content to write."},
|
|
302
|
+
},
|
|
303
|
+
"required": ["path", "content"],
|
|
304
|
+
},
|
|
305
|
+
handler=_make_write_file_handler(),
|
|
306
|
+
))
|
|
307
|
+
|
|
308
|
+
server.register_tool(MCPTool(
|
|
309
|
+
name="run_shell",
|
|
310
|
+
description="Run a shell command and return its output.",
|
|
311
|
+
input_schema={
|
|
312
|
+
"type": "object",
|
|
313
|
+
"properties": {"command": {"type": "string", "description": "Shell command to execute."}},
|
|
314
|
+
"required": ["command"],
|
|
315
|
+
},
|
|
316
|
+
handler=_make_run_shell_handler(),
|
|
317
|
+
))
|
|
318
|
+
|
|
319
|
+
server.register_tool(MCPTool(
|
|
320
|
+
name="search_code",
|
|
321
|
+
description="Search the codebase for a regex pattern using ripgrep.",
|
|
322
|
+
input_schema={
|
|
323
|
+
"type": "object",
|
|
324
|
+
"properties": {
|
|
325
|
+
"pattern": {"type": "string", "description": "Regex pattern to search for."},
|
|
326
|
+
"path": {"type": "string", "description": "Root directory to search (default: '.').", "default": "."},
|
|
327
|
+
},
|
|
328
|
+
"required": ["pattern"],
|
|
329
|
+
},
|
|
330
|
+
handler=_make_search_code_handler(),
|
|
331
|
+
))
|
|
332
|
+
|
|
333
|
+
return server
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Sentry webhook payload parsing and task building for gdm."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
REQUIRED_FIELDS = frozenset({"action", "data", "installation"})
|
|
13
|
+
KNOWN_ACTIONS = frozenset({"triggered"})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class SentryFrame:
|
|
18
|
+
path: str
|
|
19
|
+
lineno: int
|
|
20
|
+
function: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class SentryTask:
|
|
25
|
+
type: str = "fix-from-prod"
|
|
26
|
+
source: str = "sentry"
|
|
27
|
+
issue_id: str = ""
|
|
28
|
+
error_message: str = ""
|
|
29
|
+
culprit: str = ""
|
|
30
|
+
frames: list[SentryFrame] = field(default_factory=list)
|
|
31
|
+
tags: dict = field(default_factory=dict)
|
|
32
|
+
auto_fix: bool = True
|
|
33
|
+
created_at: str = ""
|
|
34
|
+
reopen: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SentryPayloadParser:
|
|
38
|
+
def __init__(self, repo_root: Path = None):
|
|
39
|
+
self.repo_root = (repo_root or Path.cwd()).resolve()
|
|
40
|
+
|
|
41
|
+
def validate_payload(self, raw: dict, quarantine_dir: Path = None) -> dict:
|
|
42
|
+
"""Validate envelope. Quarantine unknown actions. Raise ValueError on missing fields."""
|
|
43
|
+
missing = REQUIRED_FIELDS - raw.keys()
|
|
44
|
+
if missing:
|
|
45
|
+
raise ValueError(f"Missing fields: {missing}")
|
|
46
|
+
if raw["action"] not in KNOWN_ACTIONS:
|
|
47
|
+
if quarantine_dir:
|
|
48
|
+
quarantine_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
ts = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
|
|
50
|
+
(quarantine_dir / f"sentry-{ts}.json").write_text(json.dumps(raw))
|
|
51
|
+
raise ValueError(f"Unknown action: {raw['action']}")
|
|
52
|
+
return raw
|
|
53
|
+
|
|
54
|
+
def resolve_frame_path(self, frame: dict) -> Optional[Path]:
|
|
55
|
+
"""Resolve frame path to repo-relative path. Returns None if outside repo or nonexistent."""
|
|
56
|
+
raw = frame.get("filename") or frame.get("abs_path", "")
|
|
57
|
+
if not raw:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
candidate = (self.repo_root / raw).resolve()
|
|
61
|
+
if not candidate.is_relative_to(self.repo_root):
|
|
62
|
+
return None
|
|
63
|
+
# Note: in tests we don't require the file to exist on disk
|
|
64
|
+
return candidate.relative_to(self.repo_root)
|
|
65
|
+
except (ValueError, OSError):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def extract_frames(self, data: dict) -> list[SentryFrame]:
|
|
69
|
+
"""Extract and sanitize frames from Sentry event data."""
|
|
70
|
+
frames = []
|
|
71
|
+
event = data.get("data", {}).get("event", {})
|
|
72
|
+
exc = event.get("exception", {}).get("values", [])
|
|
73
|
+
for exc_val in exc:
|
|
74
|
+
for frame in exc_val.get("stacktrace", {}).get("frames", []):
|
|
75
|
+
resolved = self.resolve_frame_path(frame)
|
|
76
|
+
if resolved:
|
|
77
|
+
frames.append(SentryFrame(
|
|
78
|
+
path=str(resolved),
|
|
79
|
+
lineno=frame.get("lineno", 0),
|
|
80
|
+
function=frame.get("function", ""),
|
|
81
|
+
))
|
|
82
|
+
return frames
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SentryTaskBuilder:
|
|
86
|
+
def build(self, payload: dict, frames: list[SentryFrame], auto_fix: bool = True) -> SentryTask:
|
|
87
|
+
event = payload.get("data", {}).get("event", {})
|
|
88
|
+
issue_id = str(
|
|
89
|
+
event.get("issue", {}).get("id", "")
|
|
90
|
+
or payload.get("data", {}).get("issue", {}).get("id", "unknown")
|
|
91
|
+
)
|
|
92
|
+
return SentryTask(
|
|
93
|
+
issue_id=issue_id,
|
|
94
|
+
error_message=event.get("title", event.get("message", "")),
|
|
95
|
+
culprit=event.get("culprit", ""),
|
|
96
|
+
frames=frames,
|
|
97
|
+
tags=event.get("tags", {}),
|
|
98
|
+
auto_fix=auto_fix,
|
|
99
|
+
created_at=datetime.datetime.utcnow().isoformat() + "Z",
|
|
100
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI webhook server for Sentry events.
|
|
3
|
+
Requires: pip install gdm-code[sentry]
|
|
4
|
+
Start with: gdm sentry-server --port 9322
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# Guard: FastAPI is optional
|
|
14
|
+
try:
|
|
15
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
16
|
+
from fastapi.responses import JSONResponse
|
|
17
|
+
HAS_FASTAPI = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
HAS_FASTAPI = False
|
|
20
|
+
|
|
21
|
+
from src.integrations.sentry_integration import SentryPayloadParser, SentryTaskBuilder
|
|
22
|
+
from src.integrations.webhook_security import verify_sentry_signature
|
|
23
|
+
|
|
24
|
+
INBOX_DIR = Path(os.environ.get("GDM_SENTRY_INBOX", ".context-memory/inbox"))
|
|
25
|
+
QUARANTINE_DIR = Path(".context-memory/quarantine")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_secret() -> str:
|
|
29
|
+
return os.environ.get("SENTRY_WEBHOOK_SECRET", "")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_app():
|
|
33
|
+
if not HAS_FASTAPI:
|
|
34
|
+
raise ImportError("FastAPI not installed. Run: pip install gdm-code[sentry]")
|
|
35
|
+
|
|
36
|
+
app = FastAPI(title="gdm Sentry Webhook")
|
|
37
|
+
parser = SentryPayloadParser()
|
|
38
|
+
builder = SentryTaskBuilder()
|
|
39
|
+
|
|
40
|
+
@app.post("/webhook")
|
|
41
|
+
async def webhook(request: Request):
|
|
42
|
+
body = await request.body()
|
|
43
|
+
secret = _get_secret()
|
|
44
|
+
sig = request.headers.get("sentry-hook-signature", "")
|
|
45
|
+
|
|
46
|
+
if not verify_sentry_signature(body, sig, secret):
|
|
47
|
+
raise HTTPException(status_code=403, detail="Invalid signature")
|
|
48
|
+
|
|
49
|
+
raw = json.loads(body)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
payload = parser.validate_payload(raw, quarantine_dir=QUARANTINE_DIR)
|
|
53
|
+
except ValueError as e:
|
|
54
|
+
return JSONResponse({"status": "quarantined", "detail": str(e)}, status_code=200)
|
|
55
|
+
|
|
56
|
+
event = payload.get("data", {}).get("event", {})
|
|
57
|
+
issue_id = str(event.get("issue", {}).get("id", "unknown"))
|
|
58
|
+
|
|
59
|
+
INBOX_DIR.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
inbox_file = INBOX_DIR / f"sentry-{issue_id}.json"
|
|
61
|
+
|
|
62
|
+
# Deduplication
|
|
63
|
+
if inbox_file.exists():
|
|
64
|
+
return JSONResponse({"status": "duplicate", "issue_id": issue_id})
|
|
65
|
+
|
|
66
|
+
frames = parser.extract_frames(payload)
|
|
67
|
+
auto_fix = os.environ.get("GDM_SENTRY_AUTO_FIX", "true").lower() == "true"
|
|
68
|
+
task = builder.build(payload, frames, auto_fix=auto_fix)
|
|
69
|
+
|
|
70
|
+
inbox_file.write_text(json.dumps(dataclasses.asdict(task), indent=2))
|
|
71
|
+
|
|
72
|
+
return JSONResponse({"status": "enqueued", "issue_id": issue_id})
|
|
73
|
+
|
|
74
|
+
return app
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
app = create_app() if HAS_FASTAPI else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main(port: int = 9322):
|
|
81
|
+
import uvicorn
|
|
82
|
+
uvicorn.run(create_app(), host="127.0.0.1", port=port)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""HMAC signature verification helpers for webhook integrations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def verify_github_signature(payload: bytes, header: str, secret: str) -> bool:
|
|
9
|
+
"""Verify GitHub webhook HMAC-SHA256 signature."""
|
|
10
|
+
expected = "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
11
|
+
return hmac.compare_digest(expected, header)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def verify_sentry_signature(payload: bytes, header: str, secret: str) -> bool:
|
|
15
|
+
"""Verify Sentry webhook HMAC-SHA256 signature (constant-time)."""
|
|
16
|
+
if not header:
|
|
17
|
+
return False
|
|
18
|
+
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
19
|
+
return hmac.compare_digest(expected, header)
|
src/main.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""CLI entry point for gdm code.
|
|
2
|
+
|
|
3
|
+
All subcommands live in src/cli.py. This thin shim calls the app so
|
|
4
|
+
pyproject.toml's `gdm = "src.main:main"` script works.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import version as _pkg_version, PackageNotFoundError
|
|
9
|
+
|
|
10
|
+
from src.cli import app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_version() -> str:
|
|
14
|
+
try:
|
|
15
|
+
return _pkg_version("gdm-code")
|
|
16
|
+
except PackageNotFoundError:
|
|
17
|
+
from src import __version__
|
|
18
|
+
return __version__
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main() -> None:
|
|
22
|
+
"""Entry point for the `gdm` CLI command."""
|
|
23
|
+
app()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
main()
|
src/memory/__init__.py
ADDED
|
File without changes
|