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.
- package/README.md +719 -0
- package/VERSION +1 -0
- package/docs/ANVIL-REPO-IMPLEMENTATION-PLAN.md +441 -0
- package/docs/FIRST-SKILL-TUTORIAL.md +408 -0
- package/docs/INSTALLATION-RETRO-NOTES.md +458 -0
- package/docs/INSTALLATION.md +984 -0
- package/docs/anvil-hud.md +469 -0
- package/docs/anvil-init.md +255 -0
- package/docs/anvil-state.md +210 -0
- package/docs/boris-cherny-ralph-wiggum-insights.md +608 -0
- package/docs/command-reference.md +2022 -0
- package/docs/hooks-tts.md +368 -0
- package/docs/implementation-guide.md +810 -0
- package/docs/linear-github-integration.md +247 -0
- package/docs/local-issues.md +677 -0
- package/docs/patterns/README.md +419 -0
- package/docs/planning-responsibilities.md +139 -0
- package/docs/session-workflow.md +573 -0
- package/docs/simplification-plan-template.md +297 -0
- package/docs/simplification-principles.md +129 -0
- package/docs/specifications/CCS-RALPH-INTEGRATION-DESIGN.md +633 -0
- package/docs/specifications/CCS-RESEARCH-REPORT.md +169 -0
- package/docs/specifications/PLAN-ANV-verification-ralph-wiggum.md +403 -0
- package/docs/specifications/PLAN-parallel-tracks-anvil-memory-ccs.md +494 -0
- package/docs/specifications/SPEC-ANV-VRW/component-01-verify.md +208 -0
- package/docs/specifications/SPEC-ANV-VRW/component-02-stop-gate.md +226 -0
- package/docs/specifications/SPEC-ANV-VRW/component-03-posttooluse.md +209 -0
- package/docs/specifications/SPEC-ANV-VRW/component-04-ralph-wiggum.md +604 -0
- package/docs/specifications/SPEC-ANV-VRW/component-05-atomic-actions.md +311 -0
- package/docs/specifications/SPEC-ANV-VRW/component-06-verify-subagent.md +264 -0
- package/docs/specifications/SPEC-ANV-VRW/component-07-claude-md.md +363 -0
- package/docs/specifications/SPEC-ANV-VRW/index.md +182 -0
- package/docs/specifications/SPEC-ANV-anvil-memory.md +573 -0
- package/docs/specifications/SPEC-ANV-context-checkpoints.md +781 -0
- package/docs/specifications/SPEC-ANV-verification-ralph-wiggum.md +789 -0
- package/docs/sync.md +122 -0
- package/global/CLAUDE.md +140 -0
- package/global/agents/verify-app.md +164 -0
- package/global/commands/anvil-settings.md +527 -0
- package/global/commands/anvil-sync.md +121 -0
- package/global/commands/change.md +197 -0
- package/global/commands/clarify.md +252 -0
- package/global/commands/cleanup.md +292 -0
- package/global/commands/commit-push-pr.md +207 -0
- package/global/commands/decay-review.md +127 -0
- package/global/commands/discover.md +158 -0
- package/global/commands/doc-coverage.md +122 -0
- package/global/commands/evidence.md +307 -0
- package/global/commands/explore.md +121 -0
- package/global/commands/force-exit.md +135 -0
- package/global/commands/handoff.md +191 -0
- package/global/commands/healthcheck.md +302 -0
- package/global/commands/hud.md +84 -0
- package/global/commands/insights.md +319 -0
- package/global/commands/linear-setup.md +184 -0
- package/global/commands/lint-fix.md +198 -0
- package/global/commands/orient.md +510 -0
- package/global/commands/plan.md +228 -0
- package/global/commands/ralph.md +346 -0
- package/global/commands/ready.md +182 -0
- package/global/commands/release.md +305 -0
- package/global/commands/retro.md +96 -0
- package/global/commands/shard.md +166 -0
- package/global/commands/spec.md +227 -0
- package/global/commands/sprint.md +184 -0
- package/global/commands/tasks.md +228 -0
- package/global/commands/test-and-commit.md +151 -0
- package/global/commands/validate.md +132 -0
- package/global/commands/verify.md +251 -0
- package/global/commands/weekly-review.md +156 -0
- package/global/hooks/__pycache__/ralph_context_monitor.cpython-314.pyc +0 -0
- package/global/hooks/__pycache__/statusline_agent_sync.cpython-314.pyc +0 -0
- package/global/hooks/anvil_memory_observe.ts +322 -0
- package/global/hooks/anvil_memory_session.ts +166 -0
- package/global/hooks/anvil_memory_stop.ts +187 -0
- package/global/hooks/parse_transcript.py +116 -0
- package/global/hooks/post_merge_cleanup.sh +132 -0
- package/global/hooks/post_tool_format.sh +215 -0
- package/global/hooks/ralph_context_monitor.py +240 -0
- package/global/hooks/ralph_stop.sh +502 -0
- package/global/hooks/statusline.sh +1110 -0
- package/global/hooks/statusline_agent_sync.py +224 -0
- package/global/hooks/stop_gate.sh +250 -0
- package/global/lib/.claude/anvil-state.json +21 -0
- package/global/lib/__pycache__/agent_registry.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/claim_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/coderabbit_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/config_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/coordination_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/doc_coverage_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/gate_logger.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/github_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/hygiene_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/issue_models.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/issue_provider.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/linear_data_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/linear_provider.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/local_provider.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/quality_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/ralph_state.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/state_manager.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/transcript_parser.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/verification_runner.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/verify_iteration.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/verify_subagent.cpython-314.pyc +0 -0
- package/global/lib/agent_registry.py +995 -0
- package/global/lib/anvil-state.sh +435 -0
- package/global/lib/claim_service.py +515 -0
- package/global/lib/coderabbit_service.py +314 -0
- package/global/lib/config_service.py +423 -0
- package/global/lib/coordination_service.py +331 -0
- package/global/lib/doc_coverage_service.py +1305 -0
- package/global/lib/gate_logger.py +316 -0
- package/global/lib/github_service.py +310 -0
- package/global/lib/handoff_generator.py +775 -0
- package/global/lib/hygiene_service.py +712 -0
- package/global/lib/issue_models.py +257 -0
- package/global/lib/issue_provider.py +339 -0
- package/global/lib/linear_data_service.py +210 -0
- package/global/lib/linear_provider.py +987 -0
- package/global/lib/linear_provider.py.backup +671 -0
- package/global/lib/local_provider.py +486 -0
- package/global/lib/orient_fast.py +457 -0
- package/global/lib/quality_service.py +470 -0
- package/global/lib/ralph_prompt_generator.py +563 -0
- package/global/lib/ralph_state.py +1202 -0
- package/global/lib/state_manager.py +417 -0
- package/global/lib/transcript_parser.py +597 -0
- package/global/lib/verification_runner.py +557 -0
- package/global/lib/verify_iteration.py +490 -0
- package/global/lib/verify_subagent.py +250 -0
- package/global/skills/README.md +155 -0
- package/global/skills/quality-gates/SKILL.md +252 -0
- package/global/skills/skill-template/SKILL.md +109 -0
- package/global/skills/testing-strategies/SKILL.md +337 -0
- package/global/templates/CHANGE-template.md +105 -0
- package/global/templates/HANDOFF-template.md +63 -0
- package/global/templates/PLAN-template.md +111 -0
- package/global/templates/SPEC-template.md +93 -0
- package/global/templates/ralph/PROMPT.md.template +89 -0
- package/global/templates/ralph/fix_plan.md.template +31 -0
- package/global/templates/ralph/progress.txt.template +23 -0
- package/global/tests/__pycache__/test_doc_coverage.cpython-314.pyc +0 -0
- package/global/tests/test_doc_coverage.py +520 -0
- package/global/tests/test_issue_models.py +299 -0
- package/global/tests/test_local_provider.py +323 -0
- package/global/tools/README.md +178 -0
- package/global/tools/__pycache__/anvil-hud.cpython-314.pyc +0 -0
- package/global/tools/anvil-hud.py +3622 -0
- package/global/tools/anvil-hud.py.bak +3318 -0
- package/global/tools/anvil-issue.py +432 -0
- package/global/tools/anvil-memory/CLAUDE.md +49 -0
- package/global/tools/anvil-memory/README.md +42 -0
- package/global/tools/anvil-memory/bun.lock +25 -0
- package/global/tools/anvil-memory/bunfig.toml +9 -0
- package/global/tools/anvil-memory/package.json +23 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/context-monitor.test.ts +535 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/edge-cases.test.ts +645 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/fixtures.ts +363 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/index.ts +8 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/integration.test.ts +417 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/prompt-generator.test.ts +571 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/ralph-stop.test.ts +440 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/test-utils.ts +252 -0
- package/global/tools/anvil-memory/src/__tests__/commands.test.ts +657 -0
- package/global/tools/anvil-memory/src/__tests__/db.test.ts +641 -0
- package/global/tools/anvil-memory/src/__tests__/hooks.test.ts +272 -0
- package/global/tools/anvil-memory/src/__tests__/performance.test.ts +427 -0
- package/global/tools/anvil-memory/src/__tests__/test-utils.ts +113 -0
- package/global/tools/anvil-memory/src/commands/checkpoint.ts +197 -0
- package/global/tools/anvil-memory/src/commands/get.ts +115 -0
- package/global/tools/anvil-memory/src/commands/init.ts +94 -0
- package/global/tools/anvil-memory/src/commands/observe.ts +163 -0
- package/global/tools/anvil-memory/src/commands/search.ts +112 -0
- package/global/tools/anvil-memory/src/db.ts +638 -0
- package/global/tools/anvil-memory/src/index.ts +205 -0
- package/global/tools/anvil-memory/src/types.ts +122 -0
- package/global/tools/anvil-memory/tsconfig.json +29 -0
- package/global/tools/ralph-loop.sh +359 -0
- package/package.json +45 -0
- package/scripts/anvil +822 -0
- package/scripts/extract_patterns.py +222 -0
- package/scripts/init-project.sh +541 -0
- package/scripts/install.sh +229 -0
- package/scripts/postinstall.js +41 -0
- package/scripts/rollback.sh +188 -0
- package/scripts/sync.sh +623 -0
- package/scripts/test-statusline.sh +248 -0
- package/scripts/update_claude_md.py +224 -0
- package/scripts/verify.sh +255 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LocalJsonProvider - File-based issue tracking for Anvil Framework.
|
|
3
|
+
|
|
4
|
+
Stores issues as JSON files for users who don't use Linear.
|
|
5
|
+
Provides the same IssueProvider interface for seamless integration.
|
|
6
|
+
|
|
7
|
+
Storage Structure:
|
|
8
|
+
~/.anvil/issues/
|
|
9
|
+
├── index.json # Issue index with metadata
|
|
10
|
+
└── LOCAL-001.json # Individual issue files (optional detail storage)
|
|
11
|
+
|
|
12
|
+
<project>/.claude/
|
|
13
|
+
└── issues.json # Project-specific issues (optional)
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
from local_provider import LocalJsonProvider
|
|
17
|
+
|
|
18
|
+
provider = LocalJsonProvider()
|
|
19
|
+
issue = provider.create_issue(title="New feature", priority=Priority.HIGH)
|
|
20
|
+
issues = provider.list_issues(status=IssueStatus.TODO)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import fcntl
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import tempfile
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Optional
|
|
30
|
+
import uuid
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from .issue_models import Issue, IssueStatus, Priority
|
|
34
|
+
from .issue_provider import BaseProvider
|
|
35
|
+
except ImportError:
|
|
36
|
+
from issue_models import Issue, IssueStatus, Priority
|
|
37
|
+
from issue_provider import BaseProvider
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LocalJsonProvider(BaseProvider):
|
|
41
|
+
"""
|
|
42
|
+
File-based issue provider using JSON storage.
|
|
43
|
+
|
|
44
|
+
Features:
|
|
45
|
+
- Atomic writes (temp file + rename)
|
|
46
|
+
- File locking for concurrent access
|
|
47
|
+
- Auto-generated identifiers (LOCAL-001, LOCAL-002, etc.)
|
|
48
|
+
- Support for both global and project-specific issues
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
project_path: Optional[Path] = None,
|
|
54
|
+
prefix: str = "LOCAL",
|
|
55
|
+
storage_path: Optional[Path] = None
|
|
56
|
+
):
|
|
57
|
+
"""
|
|
58
|
+
Initialize LocalJsonProvider.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
project_path: Project root for project-specific issues (optional)
|
|
62
|
+
prefix: Identifier prefix (default: "LOCAL")
|
|
63
|
+
storage_path: Custom storage path (default: ~/.anvil/issues)
|
|
64
|
+
"""
|
|
65
|
+
self.project_path = project_path or Path.cwd()
|
|
66
|
+
self.prefix = prefix
|
|
67
|
+
|
|
68
|
+
# Global storage location
|
|
69
|
+
if storage_path:
|
|
70
|
+
self.storage_path = storage_path
|
|
71
|
+
else:
|
|
72
|
+
self.storage_path = Path.home() / ".anvil" / "issues"
|
|
73
|
+
|
|
74
|
+
# Ensure storage directory exists
|
|
75
|
+
self.storage_path.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
|
|
77
|
+
# Index file path
|
|
78
|
+
self.index_path = self.storage_path / "index.json"
|
|
79
|
+
|
|
80
|
+
# Initialize index if needed
|
|
81
|
+
self._ensure_index()
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def name(self) -> str:
|
|
85
|
+
return "local"
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def is_available(self) -> bool:
|
|
89
|
+
return True # Always available for local storage
|
|
90
|
+
|
|
91
|
+
def _ensure_index(self) -> None:
|
|
92
|
+
"""Create index file if it doesn't exist."""
|
|
93
|
+
if not self.index_path.exists():
|
|
94
|
+
self._write_index({
|
|
95
|
+
"version": "1.0",
|
|
96
|
+
"prefix": self.prefix,
|
|
97
|
+
"next_id": 1,
|
|
98
|
+
"issues": {}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
def _read_index(self) -> dict:
|
|
102
|
+
"""Read index file with file locking."""
|
|
103
|
+
try:
|
|
104
|
+
with open(self.index_path, 'r') as f:
|
|
105
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
|
106
|
+
try:
|
|
107
|
+
return json.load(f)
|
|
108
|
+
finally:
|
|
109
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
110
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
111
|
+
return {
|
|
112
|
+
"version": "1.0",
|
|
113
|
+
"prefix": self.prefix,
|
|
114
|
+
"next_id": 1,
|
|
115
|
+
"issues": {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def _write_index(self, data: dict) -> None:
|
|
119
|
+
"""Write index file atomically with file locking."""
|
|
120
|
+
# Write to temp file first
|
|
121
|
+
fd, temp_path = tempfile.mkstemp(
|
|
122
|
+
dir=self.storage_path,
|
|
123
|
+
prefix=".index_",
|
|
124
|
+
suffix=".json"
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
with os.fdopen(fd, 'w') as f:
|
|
128
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
129
|
+
try:
|
|
130
|
+
json.dump(data, f, indent=2, default=str)
|
|
131
|
+
finally:
|
|
132
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
133
|
+
|
|
134
|
+
# Atomic rename
|
|
135
|
+
os.rename(temp_path, self.index_path)
|
|
136
|
+
except Exception:
|
|
137
|
+
# Clean up temp file on error
|
|
138
|
+
try:
|
|
139
|
+
os.unlink(temp_path)
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
raise
|
|
143
|
+
|
|
144
|
+
def _generate_identifier(self, index: dict) -> tuple[str, int]:
|
|
145
|
+
"""Generate next identifier and increment counter."""
|
|
146
|
+
next_id = index.get("next_id", 1)
|
|
147
|
+
prefix = index.get("prefix", self.prefix)
|
|
148
|
+
identifier = f"{prefix}-{next_id:03d}"
|
|
149
|
+
return identifier, next_id + 1
|
|
150
|
+
|
|
151
|
+
def _issue_to_index_entry(self, issue: Issue) -> dict:
|
|
152
|
+
"""Convert Issue to compact index entry."""
|
|
153
|
+
return {
|
|
154
|
+
"id": issue.id,
|
|
155
|
+
"title": issue.title,
|
|
156
|
+
"status": issue.status.value,
|
|
157
|
+
"priority": issue.priority.value,
|
|
158
|
+
"parent_id": issue.parent_id,
|
|
159
|
+
"assigned_agent": issue.assigned_agent,
|
|
160
|
+
"labels": issue.labels,
|
|
161
|
+
"estimate": issue.estimate,
|
|
162
|
+
"created_at": issue.created_at.isoformat() if issue.created_at else None,
|
|
163
|
+
"updated_at": issue.updated_at.isoformat() if issue.updated_at else None,
|
|
164
|
+
"completed_at": issue.completed_at.isoformat() if issue.completed_at else None,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
def _index_entry_to_issue(self, identifier: str, entry: dict) -> Issue:
|
|
168
|
+
"""Convert index entry back to Issue object."""
|
|
169
|
+
def parse_dt(val) -> Optional[datetime]:
|
|
170
|
+
if val is None:
|
|
171
|
+
return None
|
|
172
|
+
if isinstance(val, datetime):
|
|
173
|
+
return val
|
|
174
|
+
return datetime.fromisoformat(val.replace('Z', '+00:00'))
|
|
175
|
+
|
|
176
|
+
return Issue(
|
|
177
|
+
id=entry.get("id", identifier),
|
|
178
|
+
identifier=identifier,
|
|
179
|
+
title=entry.get("title", ""),
|
|
180
|
+
description=entry.get("description", ""),
|
|
181
|
+
status=IssueStatus(entry.get("status", "todo")),
|
|
182
|
+
priority=Priority(entry.get("priority", 2)),
|
|
183
|
+
parent_id=entry.get("parent_id"),
|
|
184
|
+
labels=entry.get("labels", []),
|
|
185
|
+
assigned_agent=entry.get("assigned_agent"),
|
|
186
|
+
created_at=parse_dt(entry.get("created_at")) or datetime.now(timezone.utc),
|
|
187
|
+
updated_at=parse_dt(entry.get("updated_at")) or datetime.now(timezone.utc),
|
|
188
|
+
completed_at=parse_dt(entry.get("completed_at")),
|
|
189
|
+
estimate=entry.get("estimate"),
|
|
190
|
+
provider="local",
|
|
191
|
+
external_id=None
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
195
|
+
# Read Operations
|
|
196
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
def list_issues(
|
|
199
|
+
self,
|
|
200
|
+
status: Optional[IssueStatus] = None,
|
|
201
|
+
project: Optional[str] = None,
|
|
202
|
+
limit: int = 50
|
|
203
|
+
) -> list[Issue]:
|
|
204
|
+
"""List issues, optionally filtered by status."""
|
|
205
|
+
index = self._read_index()
|
|
206
|
+
issues = []
|
|
207
|
+
|
|
208
|
+
for identifier, entry in index.get("issues", {}).items():
|
|
209
|
+
issue = self._index_entry_to_issue(identifier, entry)
|
|
210
|
+
|
|
211
|
+
# Filter by status if specified
|
|
212
|
+
if status and issue.status != status:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
issues.append(issue)
|
|
216
|
+
|
|
217
|
+
# Sort by priority (lower = higher priority), then by created_at
|
|
218
|
+
issues.sort(key=lambda i: (i.priority.value, i.created_at))
|
|
219
|
+
|
|
220
|
+
return issues[:limit]
|
|
221
|
+
|
|
222
|
+
def get_issue(self, identifier: str) -> Optional[Issue]:
|
|
223
|
+
"""Get a single issue by identifier."""
|
|
224
|
+
index = self._read_index()
|
|
225
|
+
entry = index.get("issues", {}).get(identifier)
|
|
226
|
+
|
|
227
|
+
if entry is None:
|
|
228
|
+
# Try case-insensitive match
|
|
229
|
+
for key, val in index.get("issues", {}).items():
|
|
230
|
+
if key.upper() == identifier.upper():
|
|
231
|
+
return self._index_entry_to_issue(key, val)
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
return self._index_entry_to_issue(identifier, entry)
|
|
235
|
+
|
|
236
|
+
def get_ready_issues(self) -> list[Issue]:
|
|
237
|
+
"""Get issues ready to work on (Todo, no blocking deps)."""
|
|
238
|
+
issues = self.list_issues(status=IssueStatus.TODO)
|
|
239
|
+
# Sort by priority (lower = higher), then by age (oldest first)
|
|
240
|
+
return sorted(issues, key=lambda i: (i.priority.value, i.created_at))
|
|
241
|
+
|
|
242
|
+
def get_in_progress_issues(self) -> list[Issue]:
|
|
243
|
+
"""Get issues currently being worked on."""
|
|
244
|
+
in_progress = self.list_issues(status=IssueStatus.IN_PROGRESS)
|
|
245
|
+
in_review = self.list_issues(status=IssueStatus.IN_REVIEW)
|
|
246
|
+
return in_progress + in_review
|
|
247
|
+
|
|
248
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
249
|
+
# Write Operations
|
|
250
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
def create_issue(
|
|
253
|
+
self,
|
|
254
|
+
title: str,
|
|
255
|
+
description: str = "",
|
|
256
|
+
status: IssueStatus = IssueStatus.TODO,
|
|
257
|
+
priority: Priority = Priority.MEDIUM,
|
|
258
|
+
parent_id: Optional[str] = None,
|
|
259
|
+
labels: Optional[list[str]] = None
|
|
260
|
+
) -> Issue:
|
|
261
|
+
"""Create a new issue."""
|
|
262
|
+
index = self._read_index()
|
|
263
|
+
|
|
264
|
+
# Generate identifier
|
|
265
|
+
identifier, next_id = self._generate_identifier(index)
|
|
266
|
+
|
|
267
|
+
# Create issue object
|
|
268
|
+
now = datetime.now(timezone.utc)
|
|
269
|
+
issue = Issue(
|
|
270
|
+
id=str(uuid.uuid4()),
|
|
271
|
+
identifier=identifier,
|
|
272
|
+
title=title,
|
|
273
|
+
description=description,
|
|
274
|
+
status=status,
|
|
275
|
+
priority=priority,
|
|
276
|
+
parent_id=parent_id,
|
|
277
|
+
labels=labels or [],
|
|
278
|
+
assigned_agent=None,
|
|
279
|
+
created_at=now,
|
|
280
|
+
updated_at=now,
|
|
281
|
+
completed_at=None,
|
|
282
|
+
estimate=None,
|
|
283
|
+
provider="local",
|
|
284
|
+
external_id=None
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Add to index
|
|
288
|
+
index["issues"][identifier] = self._issue_to_index_entry(issue)
|
|
289
|
+
index["next_id"] = next_id
|
|
290
|
+
|
|
291
|
+
# Write index
|
|
292
|
+
self._write_index(index)
|
|
293
|
+
|
|
294
|
+
return issue
|
|
295
|
+
|
|
296
|
+
def update_issue(
|
|
297
|
+
self,
|
|
298
|
+
identifier: str,
|
|
299
|
+
title: Optional[str] = None,
|
|
300
|
+
description: Optional[str] = None,
|
|
301
|
+
status: Optional[IssueStatus] = None,
|
|
302
|
+
priority: Optional[Priority] = None,
|
|
303
|
+
labels: Optional[list[str]] = None
|
|
304
|
+
) -> Issue:
|
|
305
|
+
"""Update an existing issue."""
|
|
306
|
+
index = self._read_index()
|
|
307
|
+
|
|
308
|
+
# Find issue (case-insensitive)
|
|
309
|
+
actual_identifier = None
|
|
310
|
+
for key in index.get("issues", {}).keys():
|
|
311
|
+
if key.upper() == identifier.upper():
|
|
312
|
+
actual_identifier = key
|
|
313
|
+
break
|
|
314
|
+
|
|
315
|
+
if actual_identifier is None:
|
|
316
|
+
raise KeyError(f"Issue not found: {identifier}")
|
|
317
|
+
|
|
318
|
+
entry = index["issues"][actual_identifier]
|
|
319
|
+
issue = self._index_entry_to_issue(actual_identifier, entry)
|
|
320
|
+
|
|
321
|
+
# Apply updates
|
|
322
|
+
if title is not None:
|
|
323
|
+
issue.title = title
|
|
324
|
+
if description is not None:
|
|
325
|
+
issue.description = description
|
|
326
|
+
if status is not None:
|
|
327
|
+
issue.transition_to(status)
|
|
328
|
+
if priority is not None:
|
|
329
|
+
issue.priority = priority
|
|
330
|
+
if labels is not None:
|
|
331
|
+
issue.labels = labels
|
|
332
|
+
|
|
333
|
+
issue.updated_at = datetime.now(timezone.utc)
|
|
334
|
+
|
|
335
|
+
# Update index
|
|
336
|
+
index["issues"][actual_identifier] = self._issue_to_index_entry(issue)
|
|
337
|
+
self._write_index(index)
|
|
338
|
+
|
|
339
|
+
return issue
|
|
340
|
+
|
|
341
|
+
def delete_issue(self, identifier: str) -> bool:
|
|
342
|
+
"""Delete an issue (soft delete - marks as cancelled)."""
|
|
343
|
+
try:
|
|
344
|
+
self.update_issue(identifier, status=IssueStatus.CANCELLED)
|
|
345
|
+
return True
|
|
346
|
+
except KeyError:
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
def hard_delete_issue(self, identifier: str) -> bool:
|
|
350
|
+
"""Permanently delete an issue from storage."""
|
|
351
|
+
index = self._read_index()
|
|
352
|
+
|
|
353
|
+
# Find issue (case-insensitive)
|
|
354
|
+
actual_identifier = None
|
|
355
|
+
for key in index.get("issues", {}).keys():
|
|
356
|
+
if key.upper() == identifier.upper():
|
|
357
|
+
actual_identifier = key
|
|
358
|
+
break
|
|
359
|
+
|
|
360
|
+
if actual_identifier is None:
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
del index["issues"][actual_identifier]
|
|
364
|
+
self._write_index(index)
|
|
365
|
+
return True
|
|
366
|
+
|
|
367
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
368
|
+
# Agent Integration
|
|
369
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
def assign_to_agent(self, identifier: str, agent_id: str) -> Issue:
|
|
372
|
+
"""Assign an issue to an Anvil agent."""
|
|
373
|
+
index = self._read_index()
|
|
374
|
+
|
|
375
|
+
# Find issue
|
|
376
|
+
actual_identifier = None
|
|
377
|
+
for key in index.get("issues", {}).keys():
|
|
378
|
+
if key.upper() == identifier.upper():
|
|
379
|
+
actual_identifier = key
|
|
380
|
+
break
|
|
381
|
+
|
|
382
|
+
if actual_identifier is None:
|
|
383
|
+
raise KeyError(f"Issue not found: {identifier}")
|
|
384
|
+
|
|
385
|
+
entry = index["issues"][actual_identifier]
|
|
386
|
+
issue = self._index_entry_to_issue(actual_identifier, entry)
|
|
387
|
+
|
|
388
|
+
issue.claim(agent_id)
|
|
389
|
+
|
|
390
|
+
# Update index
|
|
391
|
+
index["issues"][actual_identifier] = self._issue_to_index_entry(issue)
|
|
392
|
+
self._write_index(index)
|
|
393
|
+
|
|
394
|
+
return issue
|
|
395
|
+
|
|
396
|
+
def unassign_agent(self, identifier: str) -> Issue:
|
|
397
|
+
"""Remove agent assignment from an issue."""
|
|
398
|
+
index = self._read_index()
|
|
399
|
+
|
|
400
|
+
# Find issue
|
|
401
|
+
actual_identifier = None
|
|
402
|
+
for key in index.get("issues", {}).keys():
|
|
403
|
+
if key.upper() == identifier.upper():
|
|
404
|
+
actual_identifier = key
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
if actual_identifier is None:
|
|
408
|
+
raise KeyError(f"Issue not found: {identifier}")
|
|
409
|
+
|
|
410
|
+
entry = index["issues"][actual_identifier]
|
|
411
|
+
issue = self._index_entry_to_issue(actual_identifier, entry)
|
|
412
|
+
|
|
413
|
+
issue.unclaim()
|
|
414
|
+
|
|
415
|
+
# Update index
|
|
416
|
+
index["issues"][actual_identifier] = self._issue_to_index_entry(issue)
|
|
417
|
+
self._write_index(index)
|
|
418
|
+
|
|
419
|
+
return issue
|
|
420
|
+
|
|
421
|
+
def get_agent_issues(self, agent_id: str) -> list[Issue]:
|
|
422
|
+
"""Get all issues assigned to a specific agent."""
|
|
423
|
+
all_issues = self.list_issues()
|
|
424
|
+
return [i for i in all_issues if i.assigned_agent == agent_id]
|
|
425
|
+
|
|
426
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
427
|
+
# Bulk Operations
|
|
428
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
def import_issues(self, issues: list[Issue]) -> int:
|
|
431
|
+
"""Import multiple issues at once."""
|
|
432
|
+
index = self._read_index()
|
|
433
|
+
imported = 0
|
|
434
|
+
|
|
435
|
+
for issue in issues:
|
|
436
|
+
# Use existing identifier or generate new one
|
|
437
|
+
if issue.identifier and issue.identifier not in index.get("issues", {}):
|
|
438
|
+
identifier = issue.identifier
|
|
439
|
+
else:
|
|
440
|
+
identifier, next_id = self._generate_identifier(index)
|
|
441
|
+
index["next_id"] = next_id
|
|
442
|
+
issue.identifier = identifier
|
|
443
|
+
|
|
444
|
+
issue.provider = "local"
|
|
445
|
+
index["issues"][identifier] = self._issue_to_index_entry(issue)
|
|
446
|
+
imported += 1
|
|
447
|
+
|
|
448
|
+
self._write_index(index)
|
|
449
|
+
return imported
|
|
450
|
+
|
|
451
|
+
def export_issues(self, status: Optional[IssueStatus] = None) -> list[dict]:
|
|
452
|
+
"""Export issues as list of dictionaries."""
|
|
453
|
+
issues = self.list_issues(status=status, limit=9999)
|
|
454
|
+
return [issue.to_dict() for issue in issues]
|
|
455
|
+
|
|
456
|
+
def get_statistics(self) -> dict:
|
|
457
|
+
"""Get issue statistics."""
|
|
458
|
+
all_issues = self.list_issues(limit=9999)
|
|
459
|
+
|
|
460
|
+
by_status = {}
|
|
461
|
+
by_priority = {}
|
|
462
|
+
assigned = 0
|
|
463
|
+
unassigned = 0
|
|
464
|
+
|
|
465
|
+
for issue in all_issues:
|
|
466
|
+
# Count by status
|
|
467
|
+
status_name = issue.status.value
|
|
468
|
+
by_status[status_name] = by_status.get(status_name, 0) + 1
|
|
469
|
+
|
|
470
|
+
# Count by priority
|
|
471
|
+
priority_name = issue.priority.to_display()
|
|
472
|
+
by_priority[priority_name] = by_priority.get(priority_name, 0) + 1
|
|
473
|
+
|
|
474
|
+
# Count assigned
|
|
475
|
+
if issue.assigned_agent:
|
|
476
|
+
assigned += 1
|
|
477
|
+
else:
|
|
478
|
+
unassigned += 1
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
"total": len(all_issues),
|
|
482
|
+
"by_status": by_status,
|
|
483
|
+
"by_priority": by_priority,
|
|
484
|
+
"assigned": assigned,
|
|
485
|
+
"unassigned": unassigned
|
|
486
|
+
}
|