anvil-dev-framework 0.1.6

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 (190) hide show
  1. package/README.md +719 -0
  2. package/VERSION +1 -0
  3. package/docs/ANVIL-REPO-IMPLEMENTATION-PLAN.md +441 -0
  4. package/docs/FIRST-SKILL-TUTORIAL.md +408 -0
  5. package/docs/INSTALLATION-RETRO-NOTES.md +458 -0
  6. package/docs/INSTALLATION.md +984 -0
  7. package/docs/anvil-hud.md +469 -0
  8. package/docs/anvil-init.md +255 -0
  9. package/docs/anvil-state.md +210 -0
  10. package/docs/boris-cherny-ralph-wiggum-insights.md +608 -0
  11. package/docs/command-reference.md +2022 -0
  12. package/docs/hooks-tts.md +368 -0
  13. package/docs/implementation-guide.md +810 -0
  14. package/docs/linear-github-integration.md +247 -0
  15. package/docs/local-issues.md +677 -0
  16. package/docs/patterns/README.md +419 -0
  17. package/docs/planning-responsibilities.md +139 -0
  18. package/docs/session-workflow.md +573 -0
  19. package/docs/simplification-plan-template.md +297 -0
  20. package/docs/simplification-principles.md +129 -0
  21. package/docs/specifications/CCS-RALPH-INTEGRATION-DESIGN.md +633 -0
  22. package/docs/specifications/CCS-RESEARCH-REPORT.md +169 -0
  23. package/docs/specifications/PLAN-ANV-verification-ralph-wiggum.md +403 -0
  24. package/docs/specifications/PLAN-parallel-tracks-anvil-memory-ccs.md +494 -0
  25. package/docs/specifications/SPEC-ANV-VRW/component-01-verify.md +208 -0
  26. package/docs/specifications/SPEC-ANV-VRW/component-02-stop-gate.md +226 -0
  27. package/docs/specifications/SPEC-ANV-VRW/component-03-posttooluse.md +209 -0
  28. package/docs/specifications/SPEC-ANV-VRW/component-04-ralph-wiggum.md +604 -0
  29. package/docs/specifications/SPEC-ANV-VRW/component-05-atomic-actions.md +311 -0
  30. package/docs/specifications/SPEC-ANV-VRW/component-06-verify-subagent.md +264 -0
  31. package/docs/specifications/SPEC-ANV-VRW/component-07-claude-md.md +363 -0
  32. package/docs/specifications/SPEC-ANV-VRW/index.md +182 -0
  33. package/docs/specifications/SPEC-ANV-anvil-memory.md +573 -0
  34. package/docs/specifications/SPEC-ANV-context-checkpoints.md +781 -0
  35. package/docs/specifications/SPEC-ANV-verification-ralph-wiggum.md +789 -0
  36. package/docs/sync.md +122 -0
  37. package/global/CLAUDE.md +140 -0
  38. package/global/agents/verify-app.md +164 -0
  39. package/global/commands/anvil-settings.md +527 -0
  40. package/global/commands/anvil-sync.md +121 -0
  41. package/global/commands/change.md +197 -0
  42. package/global/commands/clarify.md +252 -0
  43. package/global/commands/cleanup.md +292 -0
  44. package/global/commands/commit-push-pr.md +207 -0
  45. package/global/commands/decay-review.md +127 -0
  46. package/global/commands/discover.md +158 -0
  47. package/global/commands/doc-coverage.md +122 -0
  48. package/global/commands/evidence.md +307 -0
  49. package/global/commands/explore.md +121 -0
  50. package/global/commands/force-exit.md +135 -0
  51. package/global/commands/handoff.md +191 -0
  52. package/global/commands/healthcheck.md +302 -0
  53. package/global/commands/hud.md +84 -0
  54. package/global/commands/insights.md +319 -0
  55. package/global/commands/linear-setup.md +184 -0
  56. package/global/commands/lint-fix.md +198 -0
  57. package/global/commands/orient.md +510 -0
  58. package/global/commands/plan.md +228 -0
  59. package/global/commands/ralph.md +346 -0
  60. package/global/commands/ready.md +182 -0
  61. package/global/commands/release.md +305 -0
  62. package/global/commands/retro.md +96 -0
  63. package/global/commands/shard.md +166 -0
  64. package/global/commands/spec.md +227 -0
  65. package/global/commands/sprint.md +184 -0
  66. package/global/commands/tasks.md +228 -0
  67. package/global/commands/test-and-commit.md +151 -0
  68. package/global/commands/validate.md +132 -0
  69. package/global/commands/verify.md +251 -0
  70. package/global/commands/weekly-review.md +156 -0
  71. package/global/hooks/__pycache__/ralph_context_monitor.cpython-314.pyc +0 -0
  72. package/global/hooks/__pycache__/statusline_agent_sync.cpython-314.pyc +0 -0
  73. package/global/hooks/anvil_memory_observe.ts +322 -0
  74. package/global/hooks/anvil_memory_session.ts +166 -0
  75. package/global/hooks/anvil_memory_stop.ts +187 -0
  76. package/global/hooks/parse_transcript.py +116 -0
  77. package/global/hooks/post_merge_cleanup.sh +132 -0
  78. package/global/hooks/post_tool_format.sh +215 -0
  79. package/global/hooks/ralph_context_monitor.py +240 -0
  80. package/global/hooks/ralph_stop.sh +502 -0
  81. package/global/hooks/statusline.sh +1110 -0
  82. package/global/hooks/statusline_agent_sync.py +224 -0
  83. package/global/hooks/stop_gate.sh +250 -0
  84. package/global/lib/.claude/anvil-state.json +21 -0
  85. package/global/lib/__pycache__/agent_registry.cpython-314.pyc +0 -0
  86. package/global/lib/__pycache__/claim_service.cpython-314.pyc +0 -0
  87. package/global/lib/__pycache__/coderabbit_service.cpython-314.pyc +0 -0
  88. package/global/lib/__pycache__/config_service.cpython-314.pyc +0 -0
  89. package/global/lib/__pycache__/coordination_service.cpython-314.pyc +0 -0
  90. package/global/lib/__pycache__/doc_coverage_service.cpython-314.pyc +0 -0
  91. package/global/lib/__pycache__/gate_logger.cpython-314.pyc +0 -0
  92. package/global/lib/__pycache__/github_service.cpython-314.pyc +0 -0
  93. package/global/lib/__pycache__/hygiene_service.cpython-314.pyc +0 -0
  94. package/global/lib/__pycache__/issue_models.cpython-314.pyc +0 -0
  95. package/global/lib/__pycache__/issue_provider.cpython-314.pyc +0 -0
  96. package/global/lib/__pycache__/linear_data_service.cpython-314.pyc +0 -0
  97. package/global/lib/__pycache__/linear_provider.cpython-314.pyc +0 -0
  98. package/global/lib/__pycache__/local_provider.cpython-314.pyc +0 -0
  99. package/global/lib/__pycache__/quality_service.cpython-314.pyc +0 -0
  100. package/global/lib/__pycache__/ralph_state.cpython-314.pyc +0 -0
  101. package/global/lib/__pycache__/state_manager.cpython-314.pyc +0 -0
  102. package/global/lib/__pycache__/transcript_parser.cpython-314.pyc +0 -0
  103. package/global/lib/__pycache__/verification_runner.cpython-314.pyc +0 -0
  104. package/global/lib/__pycache__/verify_iteration.cpython-314.pyc +0 -0
  105. package/global/lib/__pycache__/verify_subagent.cpython-314.pyc +0 -0
  106. package/global/lib/agent_registry.py +995 -0
  107. package/global/lib/anvil-state.sh +435 -0
  108. package/global/lib/claim_service.py +515 -0
  109. package/global/lib/coderabbit_service.py +314 -0
  110. package/global/lib/config_service.py +423 -0
  111. package/global/lib/coordination_service.py +331 -0
  112. package/global/lib/doc_coverage_service.py +1305 -0
  113. package/global/lib/gate_logger.py +316 -0
  114. package/global/lib/github_service.py +310 -0
  115. package/global/lib/handoff_generator.py +775 -0
  116. package/global/lib/hygiene_service.py +712 -0
  117. package/global/lib/issue_models.py +257 -0
  118. package/global/lib/issue_provider.py +339 -0
  119. package/global/lib/linear_data_service.py +210 -0
  120. package/global/lib/linear_provider.py +987 -0
  121. package/global/lib/linear_provider.py.backup +671 -0
  122. package/global/lib/local_provider.py +486 -0
  123. package/global/lib/orient_fast.py +457 -0
  124. package/global/lib/quality_service.py +470 -0
  125. package/global/lib/ralph_prompt_generator.py +563 -0
  126. package/global/lib/ralph_state.py +1202 -0
  127. package/global/lib/state_manager.py +417 -0
  128. package/global/lib/transcript_parser.py +597 -0
  129. package/global/lib/verification_runner.py +557 -0
  130. package/global/lib/verify_iteration.py +490 -0
  131. package/global/lib/verify_subagent.py +250 -0
  132. package/global/skills/README.md +155 -0
  133. package/global/skills/quality-gates/SKILL.md +252 -0
  134. package/global/skills/skill-template/SKILL.md +109 -0
  135. package/global/skills/testing-strategies/SKILL.md +337 -0
  136. package/global/templates/CHANGE-template.md +105 -0
  137. package/global/templates/HANDOFF-template.md +63 -0
  138. package/global/templates/PLAN-template.md +111 -0
  139. package/global/templates/SPEC-template.md +93 -0
  140. package/global/templates/ralph/PROMPT.md.template +89 -0
  141. package/global/templates/ralph/fix_plan.md.template +31 -0
  142. package/global/templates/ralph/progress.txt.template +23 -0
  143. package/global/tests/__pycache__/test_doc_coverage.cpython-314.pyc +0 -0
  144. package/global/tests/test_doc_coverage.py +520 -0
  145. package/global/tests/test_issue_models.py +299 -0
  146. package/global/tests/test_local_provider.py +323 -0
  147. package/global/tools/README.md +178 -0
  148. package/global/tools/__pycache__/anvil-hud.cpython-314.pyc +0 -0
  149. package/global/tools/anvil-hud.py +3622 -0
  150. package/global/tools/anvil-hud.py.bak +3318 -0
  151. package/global/tools/anvil-issue.py +432 -0
  152. package/global/tools/anvil-memory/CLAUDE.md +49 -0
  153. package/global/tools/anvil-memory/README.md +42 -0
  154. package/global/tools/anvil-memory/bun.lock +25 -0
  155. package/global/tools/anvil-memory/bunfig.toml +9 -0
  156. package/global/tools/anvil-memory/package.json +23 -0
  157. package/global/tools/anvil-memory/src/__tests__/ccs/context-monitor.test.ts +535 -0
  158. package/global/tools/anvil-memory/src/__tests__/ccs/edge-cases.test.ts +645 -0
  159. package/global/tools/anvil-memory/src/__tests__/ccs/fixtures.ts +363 -0
  160. package/global/tools/anvil-memory/src/__tests__/ccs/index.ts +8 -0
  161. package/global/tools/anvil-memory/src/__tests__/ccs/integration.test.ts +417 -0
  162. package/global/tools/anvil-memory/src/__tests__/ccs/prompt-generator.test.ts +571 -0
  163. package/global/tools/anvil-memory/src/__tests__/ccs/ralph-stop.test.ts +440 -0
  164. package/global/tools/anvil-memory/src/__tests__/ccs/test-utils.ts +252 -0
  165. package/global/tools/anvil-memory/src/__tests__/commands.test.ts +657 -0
  166. package/global/tools/anvil-memory/src/__tests__/db.test.ts +641 -0
  167. package/global/tools/anvil-memory/src/__tests__/hooks.test.ts +272 -0
  168. package/global/tools/anvil-memory/src/__tests__/performance.test.ts +427 -0
  169. package/global/tools/anvil-memory/src/__tests__/test-utils.ts +113 -0
  170. package/global/tools/anvil-memory/src/commands/checkpoint.ts +197 -0
  171. package/global/tools/anvil-memory/src/commands/get.ts +115 -0
  172. package/global/tools/anvil-memory/src/commands/init.ts +94 -0
  173. package/global/tools/anvil-memory/src/commands/observe.ts +163 -0
  174. package/global/tools/anvil-memory/src/commands/search.ts +112 -0
  175. package/global/tools/anvil-memory/src/db.ts +638 -0
  176. package/global/tools/anvil-memory/src/index.ts +205 -0
  177. package/global/tools/anvil-memory/src/types.ts +122 -0
  178. package/global/tools/anvil-memory/tsconfig.json +29 -0
  179. package/global/tools/ralph-loop.sh +359 -0
  180. package/package.json +45 -0
  181. package/scripts/anvil +822 -0
  182. package/scripts/extract_patterns.py +222 -0
  183. package/scripts/init-project.sh +541 -0
  184. package/scripts/install.sh +229 -0
  185. package/scripts/postinstall.js +41 -0
  186. package/scripts/rollback.sh +188 -0
  187. package/scripts/sync.sh +623 -0
  188. package/scripts/test-statusline.sh +248 -0
  189. package/scripts/update_claude_md.py +224 -0
  190. package/scripts/verify.sh +255 -0
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ gate_logger.py - Stop Gate Event Logging (ANV-155)
4
+
5
+ Logs all verification gate events for debugging and audit purposes.
6
+ Events are written to .claude/logs/stop_gate.log.
7
+
8
+ Usage:
9
+ from gate_logger import GateLogger
10
+
11
+ logger = GateLogger()
12
+ logger.log_gate_trigger()
13
+ logger.log_check_result("tests", passed=True, count=47)
14
+ logger.log_gate_result(passed=True)
15
+ """
16
+
17
+ import json
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Literal, Optional
22
+
23
+
24
+ @dataclass
25
+ class GateEvent:
26
+ """A single gate event."""
27
+ timestamp: str
28
+ level: Literal["INFO", "WARN", "ERROR", "OK"]
29
+ event_type: str
30
+ message: str
31
+ details: Dict[str, Any] = field(default_factory=dict)
32
+
33
+ def to_log_line(self) -> str:
34
+ """Format as log line."""
35
+ base = f"[{self.timestamp}] [{self.level}] {self.event_type}: {self.message}"
36
+ if self.details:
37
+ base += f" | {json.dumps(self.details)}"
38
+ return base
39
+
40
+ def to_dict(self) -> Dict[str, Any]:
41
+ return {
42
+ "timestamp": self.timestamp,
43
+ "level": self.level,
44
+ "event_type": self.event_type,
45
+ "message": self.message,
46
+ "details": self.details,
47
+ }
48
+
49
+
50
+ class GateLogger:
51
+ """Logger for stop gate events."""
52
+
53
+ DEFAULT_LOG_FILE = ".claude/logs/stop_gate.log"
54
+ MAX_LOG_SIZE = 1024 * 1024 # 1MB
55
+ MAX_LOG_FILES = 5
56
+
57
+ def __init__(
58
+ self,
59
+ log_file: Optional[str] = None,
60
+ project_root: Optional[Path] = None,
61
+ ):
62
+ self.project_root = project_root or Path.cwd()
63
+ self.log_file = self.project_root / (log_file or self.DEFAULT_LOG_FILE)
64
+ self.session_events: List[GateEvent] = []
65
+
66
+ def _ensure_log_dir(self) -> None:
67
+ """Ensure log directory exists."""
68
+ self.log_file.parent.mkdir(parents=True, exist_ok=True)
69
+
70
+ def _rotate_if_needed(self) -> None:
71
+ """Rotate log file if too large."""
72
+ if not self.log_file.exists():
73
+ return
74
+
75
+ if self.log_file.stat().st_size < self.MAX_LOG_SIZE:
76
+ return
77
+
78
+ # Rotate existing files
79
+ for i in range(self.MAX_LOG_FILES - 1, 0, -1):
80
+ old_file = self.log_file.with_suffix(f".log.{i}")
81
+ new_file = self.log_file.with_suffix(f".log.{i + 1}")
82
+ if old_file.exists():
83
+ if i + 1 >= self.MAX_LOG_FILES:
84
+ old_file.unlink()
85
+ else:
86
+ old_file.rename(new_file)
87
+
88
+ # Rotate current file
89
+ self.log_file.rename(self.log_file.with_suffix(".log.1"))
90
+
91
+ def _timestamp(self) -> str:
92
+ """Get current timestamp in ISO format."""
93
+ return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
94
+
95
+ def _write_event(self, event: GateEvent) -> None:
96
+ """Write event to log file."""
97
+ self._ensure_log_dir()
98
+ self._rotate_if_needed()
99
+
100
+ with open(self.log_file, "a") as f:
101
+ f.write(event.to_log_line() + "\n")
102
+
103
+ self.session_events.append(event)
104
+
105
+ def log(
106
+ self,
107
+ level: Literal["INFO", "WARN", "ERROR", "OK"],
108
+ event_type: str,
109
+ message: str,
110
+ details: Optional[Dict[str, Any]] = None,
111
+ ) -> None:
112
+ """Log a gate event."""
113
+ event = GateEvent(
114
+ timestamp=self._timestamp(),
115
+ level=level,
116
+ event_type=event_type,
117
+ message=message,
118
+ details=details or {},
119
+ )
120
+ self._write_event(event)
121
+
122
+ # ==========================================================================
123
+ # Convenience Methods
124
+ # ==========================================================================
125
+
126
+ def log_gate_trigger(self, reason: str = "stop_event") -> None:
127
+ """Log that the gate was triggered."""
128
+ self.log("INFO", "GATE_TRIGGER", f"Stop gate triggered: {reason}")
129
+
130
+ def log_config(
131
+ self,
132
+ require_verification: bool,
133
+ force_exit: bool,
134
+ project_type: str,
135
+ ) -> None:
136
+ """Log gate configuration."""
137
+ self.log(
138
+ "INFO",
139
+ "CONFIG",
140
+ "Gate config loaded",
141
+ {
142
+ "require_verification": require_verification,
143
+ "force_exit": force_exit,
144
+ "project_type": project_type,
145
+ },
146
+ )
147
+
148
+ def log_check_start(self, check_type: str) -> None:
149
+ """Log start of a verification check."""
150
+ self.log("INFO", "CHECK_START", f"Starting {check_type} check")
151
+
152
+ def log_check_result(
153
+ self,
154
+ check_type: str,
155
+ passed: bool,
156
+ count: Optional[int] = None,
157
+ error_count: Optional[int] = None,
158
+ duration_ms: Optional[int] = None,
159
+ ) -> None:
160
+ """Log result of a verification check."""
161
+ level = "OK" if passed else "ERROR"
162
+ status = "passed" if passed else "failed"
163
+
164
+ details: Dict[str, Any] = {"passed": passed}
165
+ if count is not None:
166
+ details["count"] = count
167
+ if error_count is not None:
168
+ details["error_count"] = error_count
169
+ if duration_ms is not None:
170
+ details["duration_ms"] = duration_ms
171
+
172
+ self.log(level, "CHECK_RESULT", f"{check_type} {status}", details)
173
+
174
+ def log_gate_result(
175
+ self,
176
+ passed: bool,
177
+ failing_checks: Optional[List[str]] = None,
178
+ ) -> None:
179
+ """Log final gate decision."""
180
+ if passed:
181
+ self.log("OK", "GATE_RESULT", "Gate PASSED - allowing exit")
182
+ else:
183
+ checks = ", ".join(failing_checks or ["unknown"])
184
+ self.log(
185
+ "ERROR",
186
+ "GATE_RESULT",
187
+ f"Gate BLOCKED - failures: {checks}",
188
+ {"failing_checks": failing_checks or []},
189
+ )
190
+
191
+ def log_force_exit(self, reason: str) -> None:
192
+ """Log force exit bypass."""
193
+ self.log(
194
+ "WARN",
195
+ "FORCE_EXIT",
196
+ "Force exit requested",
197
+ {"reason": reason},
198
+ )
199
+
200
+ def log_verification_state_used(self, status: str) -> None:
201
+ """Log that cached verification state was used."""
202
+ self.log(
203
+ "INFO",
204
+ "STATE_CACHE",
205
+ f"Using cached verification state: {status}",
206
+ )
207
+
208
+ def log_error(self, error: str, details: Optional[Dict[str, Any]] = None) -> None:
209
+ """Log an error."""
210
+ self.log("ERROR", "ERROR", error, details)
211
+
212
+ # ==========================================================================
213
+ # Session Summary
214
+ # ==========================================================================
215
+
216
+ def get_session_summary(self) -> Dict[str, Any]:
217
+ """Get summary of events in current session."""
218
+ checks_passed = []
219
+ checks_failed = []
220
+ gate_result = None
221
+
222
+ for event in self.session_events:
223
+ if event.event_type == "CHECK_RESULT":
224
+ check_type = event.message.split()[0]
225
+ if event.details.get("passed"):
226
+ checks_passed.append(check_type)
227
+ else:
228
+ checks_failed.append(check_type)
229
+ elif event.event_type == "GATE_RESULT":
230
+ gate_result = "passed" if "PASSED" in event.message else "blocked"
231
+
232
+ return {
233
+ "event_count": len(self.session_events),
234
+ "checks_passed": checks_passed,
235
+ "checks_failed": checks_failed,
236
+ "gate_result": gate_result,
237
+ }
238
+
239
+ def read_recent_events(self, count: int = 50) -> List[Dict[str, Any]]:
240
+ """Read recent events from log file."""
241
+ if not self.log_file.exists():
242
+ return []
243
+
244
+ try:
245
+ with open(self.log_file, "r") as f:
246
+ lines = f.readlines()
247
+
248
+ events = []
249
+ for line in lines[-count:]:
250
+ # Parse log line
251
+ line = line.strip()
252
+ if not line:
253
+ continue
254
+
255
+ # Basic parsing: [timestamp] [level] event_type: message
256
+ try:
257
+ parts = line.split("] ", 2)
258
+ timestamp = parts[0][1:]
259
+ level = parts[1][1:]
260
+ rest = parts[2]
261
+ event_type, message = rest.split(": ", 1)
262
+
263
+ events.append({
264
+ "timestamp": timestamp,
265
+ "level": level,
266
+ "event_type": event_type,
267
+ "message": message,
268
+ })
269
+ except (IndexError, ValueError):
270
+ continue
271
+
272
+ return events
273
+
274
+ except Exception:
275
+ return []
276
+
277
+
278
+ # =============================================================================
279
+ # CLI Interface
280
+ # =============================================================================
281
+
282
+ def main():
283
+ """CLI entry point for gate logger."""
284
+ import argparse
285
+
286
+ parser = argparse.ArgumentParser(description="Gate logger utility")
287
+ parser.add_argument("command", choices=["tail", "summary", "clear"])
288
+ parser.add_argument("--count", type=int, default=20, help="Number of events")
289
+ parser.add_argument("--project", type=str, help="Project root path")
290
+
291
+ args = parser.parse_args()
292
+
293
+ project_root = Path(args.project) if args.project else Path.cwd()
294
+ logger = GateLogger(project_root=project_root)
295
+
296
+ if args.command == "tail":
297
+ events = logger.read_recent_events(args.count)
298
+ for event in events:
299
+ level = event.get("level", "INFO")
300
+ msg = event.get("message", "")
301
+ print(f"[{level}] {msg}")
302
+
303
+ elif args.command == "summary":
304
+ summary = logger.get_session_summary()
305
+ print(json.dumps(summary, indent=2))
306
+
307
+ elif args.command == "clear":
308
+ if logger.log_file.exists():
309
+ logger.log_file.unlink()
310
+ print("Log file cleared")
311
+ else:
312
+ print("No log file to clear")
313
+
314
+
315
+ if __name__ == "__main__":
316
+ main()
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ github_service.py - GitHub and CI status integration for HUD (ANV-111-113)
4
+
5
+ Fetches PR and CI status using the `gh` CLI tool with caching.
6
+
7
+ Usage:
8
+ from github_service import GitHubService
9
+
10
+ service = GitHubService()
11
+ status = service.get_pr_status(project_path="/path/to/repo")
12
+ print(status["pr_state"], status["ci_status"])
13
+ """
14
+
15
+ import json
16
+ import subprocess
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional, TypedDict
20
+
21
+
22
+ class ReviewInfo(TypedDict, total=False):
23
+ """Information about a PR review."""
24
+ state: str # APPROVED, CHANGES_REQUESTED, COMMENTED, PENDING
25
+ author: str # Reviewer username
26
+ submitted_at: str # ISO timestamp
27
+
28
+
29
+ class CheckRun(TypedDict, total=False):
30
+ """Information about a CI check run."""
31
+ name: str # Check name
32
+ status: str # queued, in_progress, completed
33
+ conclusion: str # success, failure, neutral, cancelled, skipped
34
+ url: str # URL to check details
35
+
36
+
37
+ class MergeQueueEntry(TypedDict, total=False):
38
+ """Merge queue position info."""
39
+ position: int # Position in queue
40
+ estimated_time_to_merge: int # Estimated seconds until merge
41
+
42
+
43
+ class PRStatus(TypedDict, total=False):
44
+ """PR and CI status information."""
45
+ project_path: str
46
+ branch: str
47
+ pr_number: Optional[int]
48
+ pr_url: str
49
+ pr_state: str # OPEN, MERGED, CLOSED
50
+ pr_title: str
51
+ ci_status: str # passed, failed, running, pending
52
+ ci_conclusion: str # success, failure, etc.
53
+ reviews: List[ReviewInfo]
54
+ reviews_summary: str # e.g., "2 approved, 1 pending"
55
+ check_runs: List[CheckRun]
56
+ merge_queue: Optional[MergeQueueEntry]
57
+ last_updated: float
58
+
59
+
60
+ # Cache TTL in seconds
61
+ CACHE_TTL = 30.0
62
+
63
+
64
+ class GitHubService:
65
+ """Service for fetching GitHub PR and CI status (ANV-111-113)."""
66
+
67
+ def __init__(self):
68
+ """Initialize the GitHub service."""
69
+ self._cache: Dict[str, PRStatus] = {}
70
+ self._cache_timestamps: Dict[str, float] = {}
71
+
72
+ def get_pr_status(self, project_path: str, force_refresh: bool = False) -> Optional[PRStatus]:
73
+ """Get PR and CI status for a project.
74
+
75
+ Args:
76
+ project_path: Path to the git repository
77
+ force_refresh: Force refresh even if cache is fresh
78
+
79
+ Returns:
80
+ PRStatus or None if no PR exists or gh CLI not available
81
+ """
82
+ cache_key = project_path
83
+ now = time.time()
84
+
85
+ # Check cache
86
+ if not force_refresh and cache_key in self._cache:
87
+ if now - self._cache_timestamps.get(cache_key, 0) < CACHE_TTL:
88
+ return self._cache[cache_key]
89
+
90
+ # Fetch fresh data
91
+ status = self._fetch_pr_status(project_path)
92
+ if status:
93
+ self._cache[cache_key] = status
94
+ self._cache_timestamps[cache_key] = now
95
+
96
+ return status
97
+
98
+ def get_status_for_agents(self, agents: Dict[str, Any]) -> Dict[str, PRStatus]:
99
+ """Get PR status for all agent projects.
100
+
101
+ Args:
102
+ agents: Dictionary of agent_id -> agent data
103
+
104
+ Returns:
105
+ Dictionary of project_path -> PRStatus
106
+ """
107
+ results: Dict[str, PRStatus] = {}
108
+
109
+ for agent_id, agent in agents.items():
110
+ project = agent.get("project")
111
+ if project and project not in results:
112
+ status = self.get_pr_status(project)
113
+ if status:
114
+ results[project] = status
115
+
116
+ return results
117
+
118
+ def _fetch_pr_status(self, project_path: str) -> Optional[PRStatus]:
119
+ """Fetch PR status from GitHub using gh CLI."""
120
+ if not Path(project_path).exists():
121
+ return None
122
+
123
+ # Get current branch first
124
+ branch = self._get_current_branch(project_path)
125
+ if not branch:
126
+ return None
127
+
128
+ try:
129
+ # Try to get PR for current branch
130
+ result = subprocess.run(
131
+ [
132
+ "gh", "pr", "view",
133
+ "--json", "number,state,title,url,reviews,statusCheckRollup,mergeQueueEntry",
134
+ ],
135
+ cwd=project_path,
136
+ capture_output=True,
137
+ text=True,
138
+ timeout=10,
139
+ )
140
+
141
+ if result.returncode != 0:
142
+ # No PR exists for this branch
143
+ return {
144
+ "project_path": project_path,
145
+ "branch": branch,
146
+ "pr_number": None,
147
+ "pr_url": "",
148
+ "pr_state": "NO_PR",
149
+ "pr_title": "",
150
+ "ci_status": "none",
151
+ "ci_conclusion": "",
152
+ "reviews": [],
153
+ "reviews_summary": "",
154
+ "check_runs": [],
155
+ "merge_queue": None,
156
+ "last_updated": time.time(),
157
+ }
158
+
159
+ data = json.loads(result.stdout)
160
+ return self._parse_pr_data(data, project_path, branch)
161
+
162
+ except subprocess.TimeoutExpired:
163
+ return None
164
+ except (json.JSONDecodeError, Exception):
165
+ return None
166
+
167
+ def _get_current_branch(self, project_path: str) -> Optional[str]:
168
+ """Get current git branch name."""
169
+ try:
170
+ result = subprocess.run(
171
+ ["git", "branch", "--show-current"],
172
+ cwd=project_path,
173
+ capture_output=True,
174
+ text=True,
175
+ timeout=5,
176
+ )
177
+ return result.stdout.strip() or None
178
+ except Exception:
179
+ return None
180
+
181
+ def _parse_pr_data(self, data: Dict[str, Any], project_path: str, branch: str) -> PRStatus:
182
+ """Parse gh CLI PR data into PRStatus."""
183
+ # Parse reviews
184
+ reviews: List[ReviewInfo] = []
185
+ for review in data.get("reviews", []) or []:
186
+ reviews.append({
187
+ "state": review.get("state", ""),
188
+ "author": review.get("author", {}).get("login", ""),
189
+ "submitted_at": review.get("submittedAt", ""),
190
+ })
191
+
192
+ # Summarize reviews
193
+ approved = sum(1 for r in reviews if r["state"] == "APPROVED")
194
+ changes_requested = sum(1 for r in reviews if r["state"] == "CHANGES_REQUESTED")
195
+ pending = sum(1 for r in reviews if r["state"] == "PENDING")
196
+
197
+ parts = []
198
+ if approved:
199
+ parts.append(f"{approved} approved")
200
+ if changes_requested:
201
+ parts.append(f"{changes_requested} changes requested")
202
+ if pending:
203
+ parts.append(f"{pending} pending")
204
+ reviews_summary = ", ".join(parts) if parts else "No reviews"
205
+
206
+ # Parse status check rollup
207
+ status_rollup = data.get("statusCheckRollup", []) or []
208
+ check_runs: List[CheckRun] = []
209
+ ci_status = "pending"
210
+ ci_conclusion = ""
211
+
212
+ for check in status_rollup:
213
+ typename = check.get("__typename", "")
214
+ if typename == "CheckRun":
215
+ check_runs.append({
216
+ "name": check.get("name", ""),
217
+ "status": check.get("status", ""),
218
+ "conclusion": check.get("conclusion", ""),
219
+ "url": check.get("detailsUrl", ""),
220
+ })
221
+ elif typename == "StatusContext":
222
+ # StatusContext state contains values like "success", "pending", "failure"
223
+ state = check.get("state", "")
224
+ check_runs.append({
225
+ "name": check.get("context", ""),
226
+ "status": "completed" if state and state.lower() != "pending" else "pending",
227
+ "conclusion": state.lower() if state else "",
228
+ "url": check.get("targetUrl", ""),
229
+ })
230
+
231
+ # Determine overall CI status
232
+ if not check_runs:
233
+ ci_status = "none"
234
+ else:
235
+ statuses = [c.get("status", "").lower() for c in check_runs]
236
+ conclusions = [c.get("conclusion", "").lower() for c in check_runs]
237
+
238
+ if "in_progress" in statuses or "queued" in statuses:
239
+ ci_status = "running"
240
+ elif all(c == "completed" for c in statuses):
241
+ if "failure" in conclusions or "cancelled" in conclusions:
242
+ ci_status = "failed"
243
+ ci_conclusion = "failure"
244
+ elif all(c in ("success", "skipped", "neutral") for c in conclusions if c):
245
+ ci_status = "passed"
246
+ ci_conclusion = "success"
247
+ else:
248
+ ci_status = "mixed"
249
+
250
+ # Parse merge queue
251
+ merge_queue_data = data.get("mergeQueueEntry")
252
+ merge_queue: Optional[MergeQueueEntry] = None
253
+ if merge_queue_data:
254
+ merge_queue = {
255
+ "position": merge_queue_data.get("position", 0),
256
+ "estimated_time_to_merge": merge_queue_data.get("estimatedTimeToMerge", 0),
257
+ }
258
+
259
+ return {
260
+ "project_path": project_path,
261
+ "branch": branch,
262
+ "pr_number": data.get("number"),
263
+ "pr_url": data.get("url", ""),
264
+ "pr_state": data.get("state", "OPEN"),
265
+ "pr_title": data.get("title", ""),
266
+ "ci_status": ci_status,
267
+ "ci_conclusion": ci_conclusion,
268
+ "reviews": reviews,
269
+ "reviews_summary": reviews_summary,
270
+ "check_runs": check_runs,
271
+ "merge_queue": merge_queue,
272
+ "last_updated": time.time(),
273
+ }
274
+
275
+ def clear_cache(self) -> None:
276
+ """Clear cached PR status."""
277
+ self._cache.clear()
278
+ self._cache_timestamps.clear()
279
+
280
+
281
+ # Singleton instance
282
+ _service: Optional[GitHubService] = None
283
+
284
+
285
+ def get_github_service() -> GitHubService:
286
+ """Get singleton service instance."""
287
+ global _service
288
+ if _service is None:
289
+ _service = GitHubService()
290
+ return _service
291
+
292
+
293
+ if __name__ == "__main__":
294
+ # Simple test
295
+ import sys
296
+
297
+ project = sys.argv[1] if len(sys.argv) > 1 else "."
298
+ service = GitHubService()
299
+ status = service.get_pr_status(project)
300
+
301
+ if status:
302
+ print(f"Project: {status['project_path']}")
303
+ print(f"Branch: {status['branch']}")
304
+ print(f"PR: #{status.get('pr_number', 'None')} - {status['pr_state']}")
305
+ print(f"CI: {status['ci_status']}")
306
+ print(f"Reviews: {status['reviews_summary']}")
307
+ if status.get('merge_queue'):
308
+ print(f"Merge Queue: Position {status['merge_queue']['position']}")
309
+ else:
310
+ print("Could not fetch PR status")