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.
Files changed (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. 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