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,671 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LinearProvider - Linear API adapter for Anvil Framework.
|
|
3
|
+
|
|
4
|
+
Wraps the Linear GraphQL API to provide the IssueProvider interface.
|
|
5
|
+
Uses the LINEAR_API_KEY environment variable for authentication.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from linear_provider import LinearProvider
|
|
9
|
+
|
|
10
|
+
provider = LinearProvider(team_key="ANV")
|
|
11
|
+
issues = provider.list_issues(status=IssueStatus.TODO)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import requests
|
|
22
|
+
except ImportError:
|
|
23
|
+
requests = None
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from .issue_models import Issue, IssueStatus, Priority
|
|
27
|
+
from .issue_provider import BaseProvider
|
|
28
|
+
except ImportError:
|
|
29
|
+
from issue_models import Issue, IssueStatus, Priority
|
|
30
|
+
from issue_provider import BaseProvider
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LinearProvider(BaseProvider):
|
|
34
|
+
"""
|
|
35
|
+
Linear API adapter implementing IssueProvider interface.
|
|
36
|
+
|
|
37
|
+
Features:
|
|
38
|
+
- Maps Linear workflow states to IssueStatus enum
|
|
39
|
+
- Caches state UUIDs for performance
|
|
40
|
+
- Transforms Linear API responses to Issue objects
|
|
41
|
+
- Supports agent assignment via issue comments (Linear convention)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
API_URL = "https://api.linear.app/graphql"
|
|
45
|
+
|
|
46
|
+
# Map Linear state types to our IssueStatus enum
|
|
47
|
+
STATE_TYPE_MAP = {
|
|
48
|
+
"backlog": IssueStatus.BACKLOG,
|
|
49
|
+
"unstarted": IssueStatus.TODO,
|
|
50
|
+
"started": IssueStatus.IN_PROGRESS,
|
|
51
|
+
"completed": IssueStatus.DONE,
|
|
52
|
+
"canceled": IssueStatus.CANCELLED,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Map Linear state names (more specific) to our IssueStatus
|
|
56
|
+
STATE_NAME_MAP = {
|
|
57
|
+
"backlog": IssueStatus.BACKLOG,
|
|
58
|
+
"todo": IssueStatus.TODO,
|
|
59
|
+
"in progress": IssueStatus.IN_PROGRESS,
|
|
60
|
+
"in review": IssueStatus.IN_REVIEW,
|
|
61
|
+
"done": IssueStatus.DONE,
|
|
62
|
+
"cancelled": IssueStatus.CANCELLED,
|
|
63
|
+
"canceled": IssueStatus.CANCELLED,
|
|
64
|
+
"duplicate": IssueStatus.CANCELLED,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Map our IssueStatus to Linear state names (for updates)
|
|
68
|
+
STATUS_TO_STATE_NAME = {
|
|
69
|
+
IssueStatus.BACKLOG: "Backlog",
|
|
70
|
+
IssueStatus.TODO: "Todo",
|
|
71
|
+
IssueStatus.IN_PROGRESS: "In Progress",
|
|
72
|
+
IssueStatus.IN_REVIEW: "In Review",
|
|
73
|
+
IssueStatus.DONE: "Done",
|
|
74
|
+
IssueStatus.CANCELLED: "Canceled",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Linear priority (0=urgent, 1=high, 2=medium, 3=low, 4=none)
|
|
78
|
+
# to our Priority enum
|
|
79
|
+
LINEAR_PRIORITY_MAP = {
|
|
80
|
+
0: Priority.URGENT,
|
|
81
|
+
1: Priority.HIGH,
|
|
82
|
+
2: Priority.MEDIUM,
|
|
83
|
+
3: Priority.LOW,
|
|
84
|
+
4: Priority.NONE,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
PRIORITY_TO_LINEAR = {
|
|
88
|
+
Priority.URGENT: 0,
|
|
89
|
+
Priority.HIGH: 1,
|
|
90
|
+
Priority.MEDIUM: 2,
|
|
91
|
+
Priority.LOW: 3,
|
|
92
|
+
Priority.NONE: 4,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
team_key: str = "",
|
|
98
|
+
team_id: str = "",
|
|
99
|
+
api_key: Optional[str] = None
|
|
100
|
+
):
|
|
101
|
+
"""
|
|
102
|
+
Initialize LinearProvider.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
team_key: Linear team key (e.g., "ANV")
|
|
106
|
+
team_id: Linear team UUID (optional, will be looked up from team_key)
|
|
107
|
+
api_key: Linear API key (optional, defaults to LINEAR_API_KEY env var)
|
|
108
|
+
"""
|
|
109
|
+
self.team_key = team_key
|
|
110
|
+
self._team_id = team_id
|
|
111
|
+
self.api_key = api_key or os.getenv("LINEAR_API_KEY")
|
|
112
|
+
|
|
113
|
+
if not self.api_key:
|
|
114
|
+
self._available = False
|
|
115
|
+
elif requests is None:
|
|
116
|
+
self._available = False
|
|
117
|
+
else:
|
|
118
|
+
self._available = True
|
|
119
|
+
|
|
120
|
+
self.headers = {
|
|
121
|
+
"Authorization": self.api_key or "",
|
|
122
|
+
"Content-Type": "application/json"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Cache for state UUIDs (populated on first use)
|
|
126
|
+
self._state_cache: dict[str, dict] = {}
|
|
127
|
+
self._state_cache_initialized = False
|
|
128
|
+
|
|
129
|
+
# Cache for label UUIDs (populated on first use)
|
|
130
|
+
self._label_cache: dict[str, str] = {} # name -> id
|
|
131
|
+
self._label_cache_initialized = False
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def name(self) -> str:
|
|
135
|
+
return "linear"
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def is_available(self) -> bool:
|
|
139
|
+
if not self._available:
|
|
140
|
+
return False
|
|
141
|
+
# Quick connectivity check (cached after first call)
|
|
142
|
+
if not hasattr(self, "_connectivity_checked"):
|
|
143
|
+
try:
|
|
144
|
+
self._query("query { viewer { id } }")
|
|
145
|
+
self._connectivity_checked = True
|
|
146
|
+
except Exception:
|
|
147
|
+
self._connectivity_checked = False
|
|
148
|
+
return self._connectivity_checked
|
|
149
|
+
|
|
150
|
+
def _query(self, query: str, variables: Optional[dict] = None) -> dict:
|
|
151
|
+
"""Execute GraphQL query."""
|
|
152
|
+
if requests is None:
|
|
153
|
+
raise ImportError("requests module required for Linear API")
|
|
154
|
+
|
|
155
|
+
payload = {"query": query}
|
|
156
|
+
if variables:
|
|
157
|
+
payload["variables"] = variables
|
|
158
|
+
|
|
159
|
+
response = requests.post(
|
|
160
|
+
self.API_URL,
|
|
161
|
+
headers=self.headers,
|
|
162
|
+
json=payload,
|
|
163
|
+
timeout=30
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if response.status_code != 200:
|
|
167
|
+
raise Exception(f"Linear API error: {response.status_code}")
|
|
168
|
+
|
|
169
|
+
data = response.json()
|
|
170
|
+
if "errors" in data:
|
|
171
|
+
raise Exception(f"GraphQL error: {json.dumps(data['errors'])}")
|
|
172
|
+
|
|
173
|
+
return data.get("data", {})
|
|
174
|
+
|
|
175
|
+
def _get_team_id(self) -> str:
|
|
176
|
+
"""Get team UUID, looking up from team_key if needed."""
|
|
177
|
+
if self._team_id:
|
|
178
|
+
return self._team_id
|
|
179
|
+
|
|
180
|
+
if not self.team_key:
|
|
181
|
+
raise ValueError("team_key or team_id required")
|
|
182
|
+
|
|
183
|
+
# Look up team by key
|
|
184
|
+
query = """
|
|
185
|
+
query {
|
|
186
|
+
teams {
|
|
187
|
+
nodes {
|
|
188
|
+
id
|
|
189
|
+
key
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
"""
|
|
194
|
+
result = self._query(query)
|
|
195
|
+
for team in result.get("teams", {}).get("nodes", []):
|
|
196
|
+
if team.get("key") == self.team_key:
|
|
197
|
+
self._team_id = team.get("id")
|
|
198
|
+
return self._team_id
|
|
199
|
+
|
|
200
|
+
raise ValueError(f"Team not found: {self.team_key}")
|
|
201
|
+
|
|
202
|
+
def _ensure_state_cache(self):
|
|
203
|
+
"""Initialize state cache if needed."""
|
|
204
|
+
if self._state_cache_initialized:
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
team_id = self._get_team_id()
|
|
208
|
+
query = """
|
|
209
|
+
query($teamId: String!) {
|
|
210
|
+
team(id: $teamId) {
|
|
211
|
+
states {
|
|
212
|
+
nodes {
|
|
213
|
+
id
|
|
214
|
+
name
|
|
215
|
+
type
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
"""
|
|
221
|
+
result = self._query(query, {"teamId": team_id})
|
|
222
|
+
states = result.get("team", {}).get("states", {}).get("nodes", [])
|
|
223
|
+
|
|
224
|
+
for state in states:
|
|
225
|
+
name_lower = state.get("name", "").lower()
|
|
226
|
+
self._state_cache[name_lower] = state
|
|
227
|
+
|
|
228
|
+
self._state_cache_initialized = True
|
|
229
|
+
|
|
230
|
+
def _get_state_id(self, status: IssueStatus) -> Optional[str]:
|
|
231
|
+
"""Get Linear state UUID for an IssueStatus."""
|
|
232
|
+
self._ensure_state_cache()
|
|
233
|
+
|
|
234
|
+
state_name = self.STATUS_TO_STATE_NAME.get(status, "").lower()
|
|
235
|
+
if state_name in self._state_cache:
|
|
236
|
+
return self._state_cache[state_name].get("id")
|
|
237
|
+
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
def _ensure_label_cache(self):
|
|
241
|
+
"""Initialize label cache if needed."""
|
|
242
|
+
if self._label_cache_initialized:
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
team_id = self._get_team_id()
|
|
246
|
+
query = """
|
|
247
|
+
query($teamId: String!) {
|
|
248
|
+
team(id: $teamId) {
|
|
249
|
+
labels {
|
|
250
|
+
nodes {
|
|
251
|
+
id
|
|
252
|
+
name
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
"""
|
|
258
|
+
result = self._query(query, {"teamId": team_id})
|
|
259
|
+
labels = result.get("team", {}).get("labels", {}).get("nodes", [])
|
|
260
|
+
|
|
261
|
+
for label in labels:
|
|
262
|
+
name_lower = label.get("name", "").lower()
|
|
263
|
+
self._label_cache[name_lower] = label.get("id", "")
|
|
264
|
+
|
|
265
|
+
self._label_cache_initialized = True
|
|
266
|
+
|
|
267
|
+
def _get_label_ids(self, label_names: list[str]) -> list[str]:
|
|
268
|
+
"""Get Linear label UUIDs for a list of label names.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
label_names: List of label names to look up
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
List of label UUIDs (skips labels that don't exist)
|
|
275
|
+
"""
|
|
276
|
+
if not label_names:
|
|
277
|
+
return []
|
|
278
|
+
|
|
279
|
+
self._ensure_label_cache()
|
|
280
|
+
|
|
281
|
+
label_ids = []
|
|
282
|
+
for name in label_names:
|
|
283
|
+
name_lower = name.lower()
|
|
284
|
+
if name_lower in self._label_cache:
|
|
285
|
+
label_ids.append(self._label_cache[name_lower])
|
|
286
|
+
# Skip labels that don't exist rather than failing
|
|
287
|
+
|
|
288
|
+
return label_ids
|
|
289
|
+
|
|
290
|
+
def _map_state_to_status(self, state: dict) -> IssueStatus:
|
|
291
|
+
"""Map Linear state to IssueStatus."""
|
|
292
|
+
# Try name first (more specific)
|
|
293
|
+
name_lower = state.get("name", "").lower()
|
|
294
|
+
if name_lower in self.STATE_NAME_MAP:
|
|
295
|
+
return self.STATE_NAME_MAP[name_lower]
|
|
296
|
+
|
|
297
|
+
# Fall back to type
|
|
298
|
+
state_type = state.get("type", "unstarted")
|
|
299
|
+
return self.STATE_TYPE_MAP.get(state_type, IssueStatus.TODO)
|
|
300
|
+
|
|
301
|
+
def _map_priority(self, linear_priority: int) -> Priority:
|
|
302
|
+
"""Map Linear priority (0-4) to our Priority enum."""
|
|
303
|
+
return self.LINEAR_PRIORITY_MAP.get(linear_priority, Priority.MEDIUM)
|
|
304
|
+
|
|
305
|
+
def _parse_datetime(self, value: Optional[str]) -> Optional[datetime]:
|
|
306
|
+
"""Parse ISO datetime string."""
|
|
307
|
+
if not value:
|
|
308
|
+
return None
|
|
309
|
+
try:
|
|
310
|
+
return datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
311
|
+
except (ValueError, TypeError):
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
def _issue_from_linear(self, data: dict) -> Issue:
|
|
315
|
+
"""Transform Linear API response to Issue object."""
|
|
316
|
+
state = data.get("state", {})
|
|
317
|
+
assignee = data.get("assignee", {})
|
|
318
|
+
parent = data.get("parent", {})
|
|
319
|
+
labels = data.get("labels", {}).get("nodes", [])
|
|
320
|
+
|
|
321
|
+
# Extract agent assignment from description or comments
|
|
322
|
+
# Convention: [agent:swift-falcon-a3f2] in description
|
|
323
|
+
description = data.get("description", "") or ""
|
|
324
|
+
agent_id = None
|
|
325
|
+
if "[agent:" in description:
|
|
326
|
+
start = description.find("[agent:") + 7
|
|
327
|
+
end = description.find("]", start)
|
|
328
|
+
if end > start:
|
|
329
|
+
agent_id = description[start:end]
|
|
330
|
+
|
|
331
|
+
return Issue(
|
|
332
|
+
id=data.get("id", ""),
|
|
333
|
+
identifier=data.get("identifier", ""),
|
|
334
|
+
title=data.get("title", ""),
|
|
335
|
+
description=description,
|
|
336
|
+
status=self._map_state_to_status(state),
|
|
337
|
+
priority=self._map_priority(data.get("priority", 2)),
|
|
338
|
+
parent_id=parent.get("identifier") if parent else None,
|
|
339
|
+
labels=[l.get("name", "") for l in labels],
|
|
340
|
+
assigned_agent=agent_id,
|
|
341
|
+
created_at=self._parse_datetime(data.get("createdAt")),
|
|
342
|
+
updated_at=self._parse_datetime(data.get("updatedAt")),
|
|
343
|
+
completed_at=self._parse_datetime(data.get("completedAt")),
|
|
344
|
+
estimate=data.get("estimate"),
|
|
345
|
+
provider="linear",
|
|
346
|
+
external_id=data.get("id")
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
350
|
+
# Read Operations
|
|
351
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
def list_issues(
|
|
354
|
+
self,
|
|
355
|
+
status: Optional[IssueStatus] = None,
|
|
356
|
+
project: Optional[str] = None,
|
|
357
|
+
limit: int = 50
|
|
358
|
+
) -> list[Issue]:
|
|
359
|
+
"""List issues from Linear, optionally filtered by status."""
|
|
360
|
+
team_id = self._get_team_id()
|
|
361
|
+
|
|
362
|
+
query = """
|
|
363
|
+
query($teamId: String!, $first: Int) {
|
|
364
|
+
team(id: $teamId) {
|
|
365
|
+
issues(first: $first) {
|
|
366
|
+
nodes {
|
|
367
|
+
id
|
|
368
|
+
identifier
|
|
369
|
+
title
|
|
370
|
+
description
|
|
371
|
+
state {
|
|
372
|
+
id
|
|
373
|
+
name
|
|
374
|
+
type
|
|
375
|
+
}
|
|
376
|
+
assignee {
|
|
377
|
+
id
|
|
378
|
+
name
|
|
379
|
+
}
|
|
380
|
+
priority
|
|
381
|
+
estimate
|
|
382
|
+
labels {
|
|
383
|
+
nodes {
|
|
384
|
+
name
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
parent {
|
|
388
|
+
identifier
|
|
389
|
+
}
|
|
390
|
+
createdAt
|
|
391
|
+
updatedAt
|
|
392
|
+
completedAt
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
result = self._query(query, {"teamId": team_id, "first": limit})
|
|
400
|
+
nodes = result.get("team", {}).get("issues", {}).get("nodes", [])
|
|
401
|
+
|
|
402
|
+
issues = [self._issue_from_linear(n) for n in nodes]
|
|
403
|
+
|
|
404
|
+
# Filter by status if specified
|
|
405
|
+
if status:
|
|
406
|
+
issues = [i for i in issues if i.status == status]
|
|
407
|
+
|
|
408
|
+
# Sort by priority then created_at
|
|
409
|
+
issues.sort(key=lambda i: (i.priority.value, i.created_at or datetime.min.replace(tzinfo=timezone.utc)))
|
|
410
|
+
|
|
411
|
+
return issues[:limit]
|
|
412
|
+
|
|
413
|
+
def get_issue(self, identifier: str) -> Optional[Issue]:
|
|
414
|
+
"""Get a single issue by identifier (e.g., ANV-72)."""
|
|
415
|
+
query = """
|
|
416
|
+
query($id: String!) {
|
|
417
|
+
issue(id: $id) {
|
|
418
|
+
id
|
|
419
|
+
identifier
|
|
420
|
+
title
|
|
421
|
+
description
|
|
422
|
+
state {
|
|
423
|
+
id
|
|
424
|
+
name
|
|
425
|
+
type
|
|
426
|
+
}
|
|
427
|
+
assignee {
|
|
428
|
+
id
|
|
429
|
+
name
|
|
430
|
+
}
|
|
431
|
+
priority
|
|
432
|
+
estimate
|
|
433
|
+
labels {
|
|
434
|
+
nodes {
|
|
435
|
+
name
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
parent {
|
|
439
|
+
identifier
|
|
440
|
+
}
|
|
441
|
+
createdAt
|
|
442
|
+
updatedAt
|
|
443
|
+
completedAt
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
result = self._query(query, {"id": identifier})
|
|
450
|
+
issue_data = result.get("issue")
|
|
451
|
+
if issue_data:
|
|
452
|
+
return self._issue_from_linear(issue_data)
|
|
453
|
+
except Exception:
|
|
454
|
+
pass
|
|
455
|
+
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
459
|
+
# Write Operations
|
|
460
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
def create_issue(
|
|
463
|
+
self,
|
|
464
|
+
title: str,
|
|
465
|
+
description: str = "",
|
|
466
|
+
status: IssueStatus = IssueStatus.TODO,
|
|
467
|
+
priority: Priority = Priority.MEDIUM,
|
|
468
|
+
parent_id: Optional[str] = None,
|
|
469
|
+
labels: Optional[list[str]] = None
|
|
470
|
+
) -> Issue:
|
|
471
|
+
"""Create a new issue in Linear."""
|
|
472
|
+
team_id = self._get_team_id()
|
|
473
|
+
|
|
474
|
+
mutation = """
|
|
475
|
+
mutation($input: IssueCreateInput!) {
|
|
476
|
+
issueCreate(input: $input) {
|
|
477
|
+
success
|
|
478
|
+
issue {
|
|
479
|
+
id
|
|
480
|
+
identifier
|
|
481
|
+
title
|
|
482
|
+
description
|
|
483
|
+
state {
|
|
484
|
+
id
|
|
485
|
+
name
|
|
486
|
+
type
|
|
487
|
+
}
|
|
488
|
+
assignee {
|
|
489
|
+
id
|
|
490
|
+
name
|
|
491
|
+
}
|
|
492
|
+
priority
|
|
493
|
+
labels {
|
|
494
|
+
nodes {
|
|
495
|
+
name
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
parent {
|
|
499
|
+
identifier
|
|
500
|
+
}
|
|
501
|
+
createdAt
|
|
502
|
+
updatedAt
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
input_data = {
|
|
509
|
+
"teamId": team_id,
|
|
510
|
+
"title": title,
|
|
511
|
+
"description": description,
|
|
512
|
+
"priority": self.PRIORITY_TO_LINEAR.get(priority, 2)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
# Get state ID
|
|
516
|
+
state_id = self._get_state_id(status)
|
|
517
|
+
if state_id:
|
|
518
|
+
input_data["stateId"] = state_id
|
|
519
|
+
|
|
520
|
+
# Handle parent (need to resolve identifier to ID)
|
|
521
|
+
if parent_id:
|
|
522
|
+
parent = self.get_issue(parent_id)
|
|
523
|
+
if parent and parent.external_id:
|
|
524
|
+
input_data["parentId"] = parent.external_id
|
|
525
|
+
|
|
526
|
+
# Handle labels (resolve names to UUIDs)
|
|
527
|
+
if labels:
|
|
528
|
+
label_ids = self._get_label_ids(labels)
|
|
529
|
+
if label_ids:
|
|
530
|
+
input_data["labelIds"] = label_ids
|
|
531
|
+
|
|
532
|
+
result = self._query(mutation, {"input": input_data})
|
|
533
|
+
issue_data = result.get("issueCreate", {}).get("issue", {})
|
|
534
|
+
|
|
535
|
+
return self._issue_from_linear(issue_data)
|
|
536
|
+
|
|
537
|
+
def update_issue(
|
|
538
|
+
self,
|
|
539
|
+
identifier: str,
|
|
540
|
+
title: Optional[str] = None,
|
|
541
|
+
description: Optional[str] = None,
|
|
542
|
+
status: Optional[IssueStatus] = None,
|
|
543
|
+
priority: Optional[Priority] = None,
|
|
544
|
+
labels: Optional[list[str]] = None
|
|
545
|
+
) -> Issue:
|
|
546
|
+
"""Update an existing issue in Linear."""
|
|
547
|
+
mutation = """
|
|
548
|
+
mutation($id: String!, $input: IssueUpdateInput!) {
|
|
549
|
+
issueUpdate(id: $id, input: $input) {
|
|
550
|
+
success
|
|
551
|
+
issue {
|
|
552
|
+
id
|
|
553
|
+
identifier
|
|
554
|
+
title
|
|
555
|
+
description
|
|
556
|
+
state {
|
|
557
|
+
id
|
|
558
|
+
name
|
|
559
|
+
type
|
|
560
|
+
}
|
|
561
|
+
assignee {
|
|
562
|
+
id
|
|
563
|
+
name
|
|
564
|
+
}
|
|
565
|
+
priority
|
|
566
|
+
labels {
|
|
567
|
+
nodes {
|
|
568
|
+
name
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
parent {
|
|
572
|
+
identifier
|
|
573
|
+
}
|
|
574
|
+
createdAt
|
|
575
|
+
updatedAt
|
|
576
|
+
completedAt
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
input_data = {}
|
|
583
|
+
if title is not None:
|
|
584
|
+
input_data["title"] = title
|
|
585
|
+
if description is not None:
|
|
586
|
+
input_data["description"] = description
|
|
587
|
+
if priority is not None:
|
|
588
|
+
input_data["priority"] = self.PRIORITY_TO_LINEAR.get(priority, 2)
|
|
589
|
+
if status is not None:
|
|
590
|
+
state_id = self._get_state_id(status)
|
|
591
|
+
if state_id:
|
|
592
|
+
input_data["stateId"] = state_id
|
|
593
|
+
# Handle labels (resolve names to UUIDs)
|
|
594
|
+
# Empty list means clear all labels; None means don't change
|
|
595
|
+
if labels is not None:
|
|
596
|
+
label_ids = self._get_label_ids(labels) if labels else []
|
|
597
|
+
input_data["labelIds"] = label_ids
|
|
598
|
+
|
|
599
|
+
if not input_data:
|
|
600
|
+
# Nothing to update, just fetch current state
|
|
601
|
+
issue = self.get_issue(identifier)
|
|
602
|
+
if not issue:
|
|
603
|
+
raise KeyError(f"Issue not found: {identifier}")
|
|
604
|
+
return issue
|
|
605
|
+
|
|
606
|
+
result = self._query(mutation, {"id": identifier, "input": input_data})
|
|
607
|
+
issue_data = result.get("issueUpdate", {}).get("issue", {})
|
|
608
|
+
|
|
609
|
+
if not issue_data:
|
|
610
|
+
raise KeyError(f"Issue not found: {identifier}")
|
|
611
|
+
|
|
612
|
+
return self._issue_from_linear(issue_data)
|
|
613
|
+
|
|
614
|
+
def delete_issue(self, identifier: str) -> bool:
|
|
615
|
+
"""Delete an issue (marks as cancelled in Linear)."""
|
|
616
|
+
try:
|
|
617
|
+
self.update_issue(identifier, status=IssueStatus.CANCELLED)
|
|
618
|
+
return True
|
|
619
|
+
except (KeyError, Exception):
|
|
620
|
+
return False
|
|
621
|
+
|
|
622
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
623
|
+
# Agent Integration
|
|
624
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
625
|
+
|
|
626
|
+
def assign_to_agent(self, identifier: str, agent_id: str) -> Issue:
|
|
627
|
+
"""
|
|
628
|
+
Assign an issue to an Anvil agent.
|
|
629
|
+
|
|
630
|
+
Uses description convention: [agent:agent-id] appended to description.
|
|
631
|
+
This is a workaround since Linear doesn't have agent assignment natively.
|
|
632
|
+
"""
|
|
633
|
+
issue = self.get_issue(identifier)
|
|
634
|
+
if not issue:
|
|
635
|
+
raise KeyError(f"Issue not found: {identifier}")
|
|
636
|
+
|
|
637
|
+
# Remove existing agent tag if present
|
|
638
|
+
description = issue.description or ""
|
|
639
|
+
if "[agent:" in description:
|
|
640
|
+
start = description.find("[agent:")
|
|
641
|
+
end = description.find("]", start)
|
|
642
|
+
# Only remove if closing bracket found
|
|
643
|
+
if end > start:
|
|
644
|
+
description = description[:start] + description[end + 1:]
|
|
645
|
+
|
|
646
|
+
# Add new agent tag
|
|
647
|
+
description = description.strip() + f"\n\n[agent:{agent_id}]"
|
|
648
|
+
|
|
649
|
+
return self.update_issue(identifier, description=description)
|
|
650
|
+
|
|
651
|
+
def unassign_agent(self, identifier: str) -> Issue:
|
|
652
|
+
"""Remove agent assignment from an issue."""
|
|
653
|
+
issue = self.get_issue(identifier)
|
|
654
|
+
if not issue:
|
|
655
|
+
raise KeyError(f"Issue not found: {identifier}")
|
|
656
|
+
|
|
657
|
+
description = issue.description or ""
|
|
658
|
+
if "[agent:" in description:
|
|
659
|
+
start = description.find("[agent:")
|
|
660
|
+
end = description.find("]", start)
|
|
661
|
+
# Only remove if closing bracket found
|
|
662
|
+
if end > start:
|
|
663
|
+
description = description[:start] + description[end + 1:]
|
|
664
|
+
description = description.strip()
|
|
665
|
+
|
|
666
|
+
return self.update_issue(identifier, description=description)
|
|
667
|
+
|
|
668
|
+
def get_agent_issues(self, agent_id: str) -> list[Issue]:
|
|
669
|
+
"""Get all issues assigned to a specific agent."""
|
|
670
|
+
all_issues = self.list_issues()
|
|
671
|
+
return [i for i in all_issues if i.assigned_agent == agent_id]
|