mindsystem-cc 3.2.3 → 3.3.1
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 +2 -2
- package/agents/ms-code-simplifier.md +215 -0
- package/agents/ms-consolidator.md +377 -0
- package/agents/ms-executor.md +32 -261
- package/{commands/ms/simplify-flutter.md → agents/ms-flutter-simplifier.md} +41 -49
- package/agents/ms-researcher.md +2 -2
- package/commands/ms/complete-milestone.md +24 -3
- package/commands/ms/do-work.md +17 -3
- package/commands/ms/execute-phase.md +29 -14
- package/commands/ms/help.md +1 -1
- package/commands/ms/new-milestone.md +42 -30
- package/commands/ms/progress.md +1 -1
- package/commands/ms/research-project.md +8 -0
- package/commands/ms/review-design.md +1 -1
- package/mindsystem/references/git-integration.md +2 -2
- package/mindsystem/references/goal-backward.md +2 -2
- package/mindsystem/references/principles.md +1 -1
- package/mindsystem/templates/adhoc-summary.md +3 -0
- package/mindsystem/templates/codebase/architecture.md +2 -2
- package/mindsystem/templates/codebase/structure.md +1 -1
- package/mindsystem/templates/config.json +2 -1
- package/mindsystem/templates/decisions.md +145 -0
- package/mindsystem/workflows/complete-milestone.md +66 -15
- package/mindsystem/workflows/create-milestone.md +60 -20
- package/mindsystem/workflows/do-work.md +81 -4
- package/mindsystem/workflows/execute-phase.md +66 -0
- package/mindsystem/workflows/map-codebase.md +9 -1
- package/mindsystem/workflows/verify-work.md +1 -1
- package/package.json +1 -1
- package/scripts/generate-adhoc-patch.sh +79 -0
- package/scripts/ms-lookup/uv.lock +17 -17
- package/commands/ms/discuss-milestone.md +0 -48
- package/commands/ms/linear.md +0 -195
- package/mindsystem/templates/milestone-context.md +0 -93
- package/scripts/ms-linear/ms_linear/__init__.py +0 -3
- package/scripts/ms-linear/ms_linear/__main__.py +0 -6
- package/scripts/ms-linear/ms_linear/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/ms-linear/ms_linear/__pycache__/__main__.cpython-314.pyc +0 -0
- package/scripts/ms-linear/ms_linear/__pycache__/cli.cpython-314.pyc +0 -0
- package/scripts/ms-linear/ms_linear/__pycache__/client.cpython-314.pyc +0 -0
- package/scripts/ms-linear/ms_linear/__pycache__/config.cpython-314.pyc +0 -0
- package/scripts/ms-linear/ms_linear/__pycache__/errors.cpython-314.pyc +0 -0
- package/scripts/ms-linear/ms_linear/__pycache__/output.cpython-314.pyc +0 -0
- package/scripts/ms-linear/ms_linear/cli.py +0 -604
- package/scripts/ms-linear/ms_linear/client.py +0 -503
- package/scripts/ms-linear/ms_linear/config.py +0 -104
- package/scripts/ms-linear/ms_linear/errors.py +0 -29
- package/scripts/ms-linear/ms_linear/output.py +0 -45
- package/scripts/ms-linear/pyproject.toml +0 -16
- package/scripts/ms-linear/uv.lock +0 -196
- package/scripts/ms-linear-wrapper.sh +0 -22
|
@@ -1,503 +0,0 @@
|
|
|
1
|
-
"""Linear GraphQL API client."""
|
|
2
|
-
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
|
|
7
|
-
from ms_linear.config import LINEAR_API_URL, LinearConfig, get_api_key
|
|
8
|
-
from ms_linear.errors import ErrorCode, MsLinearError
|
|
9
|
-
|
|
10
|
-
# GraphQL Queries
|
|
11
|
-
QUERY_ISSUE = """
|
|
12
|
-
query Issue($id: String!) {
|
|
13
|
-
issue(id: $id) {
|
|
14
|
-
id
|
|
15
|
-
identifier
|
|
16
|
-
title
|
|
17
|
-
description
|
|
18
|
-
priority
|
|
19
|
-
estimate
|
|
20
|
-
url
|
|
21
|
-
state {
|
|
22
|
-
id
|
|
23
|
-
name
|
|
24
|
-
type
|
|
25
|
-
}
|
|
26
|
-
parent {
|
|
27
|
-
identifier
|
|
28
|
-
title
|
|
29
|
-
}
|
|
30
|
-
children {
|
|
31
|
-
nodes {
|
|
32
|
-
identifier
|
|
33
|
-
title
|
|
34
|
-
state {
|
|
35
|
-
name
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
project {
|
|
40
|
-
id
|
|
41
|
-
name
|
|
42
|
-
}
|
|
43
|
-
team {
|
|
44
|
-
id
|
|
45
|
-
key
|
|
46
|
-
name
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
"""
|
|
51
|
-
|
|
52
|
-
QUERY_WORKFLOW_STATES = """
|
|
53
|
-
query {
|
|
54
|
-
workflowStates {
|
|
55
|
-
nodes {
|
|
56
|
-
id
|
|
57
|
-
name
|
|
58
|
-
type
|
|
59
|
-
position
|
|
60
|
-
team {
|
|
61
|
-
id
|
|
62
|
-
key
|
|
63
|
-
name
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
"""
|
|
69
|
-
|
|
70
|
-
QUERY_TEAM_STATES = """
|
|
71
|
-
query TeamWorkflowStates($teamId: String!) {
|
|
72
|
-
team(id: $teamId) {
|
|
73
|
-
id
|
|
74
|
-
name
|
|
75
|
-
key
|
|
76
|
-
states {
|
|
77
|
-
nodes {
|
|
78
|
-
id
|
|
79
|
-
name
|
|
80
|
-
type
|
|
81
|
-
position
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
"""
|
|
87
|
-
|
|
88
|
-
QUERY_PROJECTS = """
|
|
89
|
-
query {
|
|
90
|
-
projects {
|
|
91
|
-
nodes {
|
|
92
|
-
id
|
|
93
|
-
name
|
|
94
|
-
state
|
|
95
|
-
team {
|
|
96
|
-
id
|
|
97
|
-
key
|
|
98
|
-
name
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
"""
|
|
104
|
-
|
|
105
|
-
QUERY_TEAM_PROJECTS = """
|
|
106
|
-
query TeamProjects($teamId: String!) {
|
|
107
|
-
team(id: $teamId) {
|
|
108
|
-
id
|
|
109
|
-
name
|
|
110
|
-
key
|
|
111
|
-
projects {
|
|
112
|
-
nodes {
|
|
113
|
-
id
|
|
114
|
-
name
|
|
115
|
-
state
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
"""
|
|
121
|
-
|
|
122
|
-
# GraphQL Mutations
|
|
123
|
-
MUTATION_CREATE_ISSUE = """
|
|
124
|
-
mutation IssueCreate($input: IssueCreateInput!) {
|
|
125
|
-
issueCreate(input: $input) {
|
|
126
|
-
success
|
|
127
|
-
issue {
|
|
128
|
-
id
|
|
129
|
-
identifier
|
|
130
|
-
title
|
|
131
|
-
url
|
|
132
|
-
state {
|
|
133
|
-
name
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
"""
|
|
139
|
-
|
|
140
|
-
MUTATION_UPDATE_ISSUE = """
|
|
141
|
-
mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
|
|
142
|
-
issueUpdate(id: $id, input: $input) {
|
|
143
|
-
success
|
|
144
|
-
issue {
|
|
145
|
-
id
|
|
146
|
-
identifier
|
|
147
|
-
title
|
|
148
|
-
url
|
|
149
|
-
state {
|
|
150
|
-
id
|
|
151
|
-
name
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
"""
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
class LinearClient:
|
|
160
|
-
"""Client for Linear GraphQL API."""
|
|
161
|
-
|
|
162
|
-
def __init__(self, api_key: str | None = None):
|
|
163
|
-
self.api_key = api_key or get_api_key()
|
|
164
|
-
self.client = httpx.Client(timeout=30.0)
|
|
165
|
-
|
|
166
|
-
def _request(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
167
|
-
"""Make a GraphQL request to Linear."""
|
|
168
|
-
headers = {
|
|
169
|
-
"Content-Type": "application/json",
|
|
170
|
-
"Authorization": self.api_key,
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
payload: dict[str, Any] = {"query": query}
|
|
174
|
-
if variables:
|
|
175
|
-
payload["variables"] = variables
|
|
176
|
-
|
|
177
|
-
try:
|
|
178
|
-
response = self.client.post(LINEAR_API_URL, headers=headers, json=payload)
|
|
179
|
-
except httpx.NetworkError as e:
|
|
180
|
-
raise MsLinearError(
|
|
181
|
-
code=ErrorCode.NETWORK_ERROR,
|
|
182
|
-
message=f"Network error: {e}",
|
|
183
|
-
suggestions=["Check your internet connection"],
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
if response.status_code == 429:
|
|
187
|
-
raise MsLinearError(
|
|
188
|
-
code=ErrorCode.RATE_LIMITED,
|
|
189
|
-
message="Rate limited by Linear API",
|
|
190
|
-
suggestions=["Wait a moment and retry"],
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
if response.status_code != 200:
|
|
194
|
-
raise MsLinearError(
|
|
195
|
-
code=ErrorCode.API_ERROR,
|
|
196
|
-
message=f"API error: HTTP {response.status_code}",
|
|
197
|
-
suggestions=["Check your API key is valid"],
|
|
198
|
-
)
|
|
199
|
-
|
|
200
|
-
try:
|
|
201
|
-
data = response.json()
|
|
202
|
-
except ValueError:
|
|
203
|
-
raise MsLinearError(
|
|
204
|
-
code=ErrorCode.INVALID_RESPONSE,
|
|
205
|
-
message="Invalid JSON response from Linear",
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
if "errors" in data:
|
|
209
|
-
error_msg = data["errors"][0].get("message", "Unknown error")
|
|
210
|
-
if "Authentication" in error_msg:
|
|
211
|
-
raise MsLinearError(
|
|
212
|
-
code=ErrorCode.MISSING_API_KEY,
|
|
213
|
-
message="Authentication failed",
|
|
214
|
-
suggestions=[
|
|
215
|
-
"Check LINEAR_API_KEY is valid",
|
|
216
|
-
"Get a new key from Linear Settings > API",
|
|
217
|
-
],
|
|
218
|
-
)
|
|
219
|
-
if "not found" in error_msg.lower():
|
|
220
|
-
raise MsLinearError(
|
|
221
|
-
code=ErrorCode.ISSUE_NOT_FOUND,
|
|
222
|
-
message=error_msg,
|
|
223
|
-
suggestions=["Check the issue identifier is correct"],
|
|
224
|
-
)
|
|
225
|
-
raise MsLinearError(
|
|
226
|
-
code=ErrorCode.API_ERROR,
|
|
227
|
-
message=error_msg,
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
return data.get("data", {})
|
|
231
|
-
|
|
232
|
-
def get_issue(self, issue_id: str) -> dict[str, Any]:
|
|
233
|
-
"""Fetch issue details by ID or identifier."""
|
|
234
|
-
data = self._request(QUERY_ISSUE, {"id": issue_id})
|
|
235
|
-
issue = data.get("issue")
|
|
236
|
-
if not issue:
|
|
237
|
-
raise MsLinearError(
|
|
238
|
-
code=ErrorCode.ISSUE_NOT_FOUND,
|
|
239
|
-
message=f"Issue {issue_id} not found",
|
|
240
|
-
suggestions=["Check the issue identifier is correct"],
|
|
241
|
-
)
|
|
242
|
-
return issue
|
|
243
|
-
|
|
244
|
-
def get_workflow_states(self, team_id: str | None = None) -> list[dict[str, Any]]:
|
|
245
|
-
"""Get workflow states, optionally filtered by team."""
|
|
246
|
-
if team_id:
|
|
247
|
-
data = self._request(QUERY_TEAM_STATES, {"teamId": team_id})
|
|
248
|
-
team = data.get("team", {})
|
|
249
|
-
states = team.get("states", {}).get("nodes", [])
|
|
250
|
-
# Add team info to each state
|
|
251
|
-
team_info = {"id": team.get("id"), "key": team.get("key"), "name": team.get("name")}
|
|
252
|
-
return [{"team": team_info, **state} for state in states]
|
|
253
|
-
else:
|
|
254
|
-
data = self._request(QUERY_WORKFLOW_STATES)
|
|
255
|
-
return data.get("workflowStates", {}).get("nodes", [])
|
|
256
|
-
|
|
257
|
-
def get_projects(self, team_id: str | None = None) -> list[dict[str, Any]]:
|
|
258
|
-
"""Get projects, optionally filtered by team."""
|
|
259
|
-
if team_id:
|
|
260
|
-
data = self._request(QUERY_TEAM_PROJECTS, {"teamId": team_id})
|
|
261
|
-
team = data.get("team", {})
|
|
262
|
-
projects = team.get("projects", {}).get("nodes", [])
|
|
263
|
-
# Add team info to each project
|
|
264
|
-
team_info = {"id": team.get("id"), "key": team.get("key"), "name": team.get("name")}
|
|
265
|
-
return [{"team": team_info, **project} for project in projects]
|
|
266
|
-
else:
|
|
267
|
-
data = self._request(QUERY_PROJECTS)
|
|
268
|
-
return data.get("projects", {}).get("nodes", [])
|
|
269
|
-
|
|
270
|
-
def find_project_by_name(self, project_name: str, team_id: str | None = None) -> dict[str, Any]:
|
|
271
|
-
"""Find a project by name, optionally within a specific team."""
|
|
272
|
-
projects = self.get_projects(team_id)
|
|
273
|
-
project_name_lower = project_name.lower()
|
|
274
|
-
|
|
275
|
-
# Exact match first
|
|
276
|
-
for project in projects:
|
|
277
|
-
if project["name"].lower() == project_name_lower:
|
|
278
|
-
return project
|
|
279
|
-
|
|
280
|
-
# Partial match
|
|
281
|
-
for project in projects:
|
|
282
|
-
if project_name_lower in project["name"].lower():
|
|
283
|
-
return project
|
|
284
|
-
|
|
285
|
-
available = ", ".join(sorted(set(p["name"] for p in projects)))
|
|
286
|
-
raise MsLinearError(
|
|
287
|
-
code=ErrorCode.PROJECT_NOT_FOUND,
|
|
288
|
-
message=f"Project '{project_name}' not found",
|
|
289
|
-
suggestions=[f"Available projects: {available}"],
|
|
290
|
-
)
|
|
291
|
-
|
|
292
|
-
def create_issue(
|
|
293
|
-
self,
|
|
294
|
-
config: LinearConfig,
|
|
295
|
-
title: str,
|
|
296
|
-
description: str | None = None,
|
|
297
|
-
priority: int | None = None,
|
|
298
|
-
estimate: int | None = None,
|
|
299
|
-
parent_id: str | None = None,
|
|
300
|
-
state_id: str | None = None,
|
|
301
|
-
label_ids: list[str] | None = None,
|
|
302
|
-
project_id: str | None = None,
|
|
303
|
-
no_project: bool = False,
|
|
304
|
-
) -> dict[str, Any]:
|
|
305
|
-
"""Create a new issue.
|
|
306
|
-
|
|
307
|
-
Args:
|
|
308
|
-
config: Linear configuration
|
|
309
|
-
title: Issue title
|
|
310
|
-
description: Issue description (markdown)
|
|
311
|
-
priority: Priority (0-4)
|
|
312
|
-
estimate: Estimate value
|
|
313
|
-
parent_id: Parent issue ID for sub-issues
|
|
314
|
-
state_id: Initial state ID
|
|
315
|
-
label_ids: Label IDs to apply
|
|
316
|
-
project_id: Explicit project ID (overrides config default)
|
|
317
|
-
no_project: If True, don't assign to any project (ignores config default)
|
|
318
|
-
"""
|
|
319
|
-
input_data: dict[str, Any] = {
|
|
320
|
-
"teamId": config.team_id,
|
|
321
|
-
"title": title,
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if description:
|
|
325
|
-
input_data["description"] = description
|
|
326
|
-
|
|
327
|
-
# Project logic: explicit > config default, unless no_project
|
|
328
|
-
if not no_project:
|
|
329
|
-
if project_id:
|
|
330
|
-
input_data["projectId"] = project_id
|
|
331
|
-
elif config.project_id:
|
|
332
|
-
input_data["projectId"] = config.project_id
|
|
333
|
-
|
|
334
|
-
if priority is not None:
|
|
335
|
-
input_data["priority"] = priority
|
|
336
|
-
else:
|
|
337
|
-
input_data["priority"] = config.default_priority
|
|
338
|
-
if estimate is not None:
|
|
339
|
-
input_data["estimate"] = estimate
|
|
340
|
-
if parent_id:
|
|
341
|
-
input_data["parentId"] = parent_id
|
|
342
|
-
if state_id:
|
|
343
|
-
input_data["stateId"] = state_id
|
|
344
|
-
if label_ids:
|
|
345
|
-
input_data["labelIds"] = label_ids
|
|
346
|
-
elif config.default_labels:
|
|
347
|
-
input_data["labelIds"] = config.default_labels
|
|
348
|
-
|
|
349
|
-
data = self._request(MUTATION_CREATE_ISSUE, {"input": input_data})
|
|
350
|
-
result = data.get("issueCreate", {})
|
|
351
|
-
|
|
352
|
-
if not result.get("success"):
|
|
353
|
-
raise MsLinearError(
|
|
354
|
-
code=ErrorCode.API_ERROR,
|
|
355
|
-
message="Failed to create issue",
|
|
356
|
-
)
|
|
357
|
-
|
|
358
|
-
return result.get("issue", {})
|
|
359
|
-
|
|
360
|
-
def update_issue(
|
|
361
|
-
self,
|
|
362
|
-
issue_id: str,
|
|
363
|
-
title: str | None = None,
|
|
364
|
-
description: str | None = None,
|
|
365
|
-
priority: int | None = None,
|
|
366
|
-
state_id: str | None = None,
|
|
367
|
-
) -> dict[str, Any]:
|
|
368
|
-
"""Update an existing issue."""
|
|
369
|
-
input_data: dict[str, Any] = {}
|
|
370
|
-
|
|
371
|
-
if title is not None:
|
|
372
|
-
input_data["title"] = title
|
|
373
|
-
if description is not None:
|
|
374
|
-
input_data["description"] = description
|
|
375
|
-
if priority is not None:
|
|
376
|
-
input_data["priority"] = priority
|
|
377
|
-
if state_id is not None:
|
|
378
|
-
input_data["stateId"] = state_id
|
|
379
|
-
|
|
380
|
-
if not input_data:
|
|
381
|
-
raise MsLinearError(
|
|
382
|
-
code=ErrorCode.INVALID_INPUT,
|
|
383
|
-
message="No fields to update",
|
|
384
|
-
suggestions=["Provide at least one field to update"],
|
|
385
|
-
)
|
|
386
|
-
|
|
387
|
-
data = self._request(MUTATION_UPDATE_ISSUE, {"id": issue_id, "input": input_data})
|
|
388
|
-
result = data.get("issueUpdate", {})
|
|
389
|
-
|
|
390
|
-
if not result.get("success"):
|
|
391
|
-
raise MsLinearError(
|
|
392
|
-
code=ErrorCode.API_ERROR,
|
|
393
|
-
message="Failed to update issue",
|
|
394
|
-
)
|
|
395
|
-
|
|
396
|
-
return result.get("issue", {})
|
|
397
|
-
|
|
398
|
-
def find_state_by_name(self, team_id: str, state_name: str) -> dict[str, Any]:
|
|
399
|
-
"""Find a workflow state by name within a team."""
|
|
400
|
-
states = self.get_workflow_states(team_id)
|
|
401
|
-
state_name_lower = state_name.lower()
|
|
402
|
-
|
|
403
|
-
# Exact match first
|
|
404
|
-
for state in states:
|
|
405
|
-
if state["name"].lower() == state_name_lower:
|
|
406
|
-
return state
|
|
407
|
-
|
|
408
|
-
# Partial match
|
|
409
|
-
for state in states:
|
|
410
|
-
if state_name_lower in state["name"].lower():
|
|
411
|
-
return state
|
|
412
|
-
|
|
413
|
-
# Type-based match (e.g., "done" matches completed type)
|
|
414
|
-
type_mapping = {
|
|
415
|
-
"done": "completed",
|
|
416
|
-
"complete": "completed",
|
|
417
|
-
"finished": "completed",
|
|
418
|
-
"todo": "unstarted",
|
|
419
|
-
"backlog": "backlog",
|
|
420
|
-
"in progress": "started",
|
|
421
|
-
"started": "started",
|
|
422
|
-
"cancelled": "canceled",
|
|
423
|
-
"canceled": "canceled",
|
|
424
|
-
}
|
|
425
|
-
if state_name_lower in type_mapping:
|
|
426
|
-
target_type = type_mapping[state_name_lower]
|
|
427
|
-
for state in states:
|
|
428
|
-
if state.get("type") == target_type:
|
|
429
|
-
return state
|
|
430
|
-
|
|
431
|
-
available = ", ".join(sorted(set(s["name"] for s in states)))
|
|
432
|
-
raise MsLinearError(
|
|
433
|
-
code=ErrorCode.STATE_NOT_FOUND,
|
|
434
|
-
message=f"State '{state_name}' not found",
|
|
435
|
-
suggestions=[f"Available states: {available}"],
|
|
436
|
-
)
|
|
437
|
-
|
|
438
|
-
def mark_done(self, issue_id: str) -> dict[str, Any]:
|
|
439
|
-
"""Mark an issue as completed."""
|
|
440
|
-
# First get the issue to find its team
|
|
441
|
-
issue = self.get_issue(issue_id)
|
|
442
|
-
team_id = issue.get("team", {}).get("id")
|
|
443
|
-
|
|
444
|
-
if not team_id:
|
|
445
|
-
raise MsLinearError(
|
|
446
|
-
code=ErrorCode.API_ERROR,
|
|
447
|
-
message="Could not determine team for issue",
|
|
448
|
-
)
|
|
449
|
-
|
|
450
|
-
# Find the completed state
|
|
451
|
-
done_state = self.find_state_by_name(team_id, "done")
|
|
452
|
-
return self.update_issue(issue_id, state_id=done_state["id"])
|
|
453
|
-
|
|
454
|
-
def change_state(self, issue_id: str, state_name: str) -> dict[str, Any]:
|
|
455
|
-
"""Change an issue's state by name."""
|
|
456
|
-
# First get the issue to find its team
|
|
457
|
-
issue = self.get_issue(issue_id)
|
|
458
|
-
team_id = issue.get("team", {}).get("id")
|
|
459
|
-
|
|
460
|
-
if not team_id:
|
|
461
|
-
raise MsLinearError(
|
|
462
|
-
code=ErrorCode.API_ERROR,
|
|
463
|
-
message="Could not determine team for issue",
|
|
464
|
-
)
|
|
465
|
-
|
|
466
|
-
# Find the target state
|
|
467
|
-
target_state = self.find_state_by_name(team_id, state_name)
|
|
468
|
-
return self.update_issue(issue_id, state_id=target_state["id"])
|
|
469
|
-
|
|
470
|
-
def create_sub_issues(
|
|
471
|
-
self,
|
|
472
|
-
config: LinearConfig,
|
|
473
|
-
parent_id: str,
|
|
474
|
-
issues: list[dict[str, Any]],
|
|
475
|
-
project_id: str | None = None,
|
|
476
|
-
no_project: bool = False,
|
|
477
|
-
) -> list[dict[str, Any]]:
|
|
478
|
-
"""Create multiple sub-issues under a parent."""
|
|
479
|
-
# Get parent issue to extract its UUID and project
|
|
480
|
-
parent = self.get_issue(parent_id)
|
|
481
|
-
parent_uuid = parent.get("id")
|
|
482
|
-
|
|
483
|
-
# Inherit parent's project if not explicitly specified
|
|
484
|
-
if not no_project and not project_id:
|
|
485
|
-
parent_project = parent.get("project")
|
|
486
|
-
if parent_project:
|
|
487
|
-
project_id = parent_project.get("id")
|
|
488
|
-
|
|
489
|
-
created = []
|
|
490
|
-
for issue_data in issues:
|
|
491
|
-
issue = self.create_issue(
|
|
492
|
-
config=config,
|
|
493
|
-
title=issue_data.get("title", ""),
|
|
494
|
-
description=issue_data.get("description"),
|
|
495
|
-
priority=issue_data.get("priority"),
|
|
496
|
-
estimate=issue_data.get("estimate"),
|
|
497
|
-
parent_id=parent_uuid,
|
|
498
|
-
project_id=project_id,
|
|
499
|
-
no_project=no_project,
|
|
500
|
-
)
|
|
501
|
-
created.append(issue)
|
|
502
|
-
|
|
503
|
-
return created
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
"""Configuration and environment variables."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
from ms_linear.errors import ErrorCode, MsLinearError
|
|
9
|
-
|
|
10
|
-
# API Configuration
|
|
11
|
-
LINEAR_API_URL = "https://api.linear.app/graphql"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclass
|
|
15
|
-
class LinearConfig:
|
|
16
|
-
"""Linear project configuration from .linear.json."""
|
|
17
|
-
|
|
18
|
-
team_id: str
|
|
19
|
-
project_id: str | None = None
|
|
20
|
-
default_priority: int = 3
|
|
21
|
-
default_labels: list[str] | None = None
|
|
22
|
-
|
|
23
|
-
def __post_init__(self) -> None:
|
|
24
|
-
if self.default_labels is None:
|
|
25
|
-
self.default_labels = []
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def get_api_key() -> str:
|
|
29
|
-
"""Get LINEAR_API_KEY from environment."""
|
|
30
|
-
api_key = os.environ.get("LINEAR_API_KEY", "")
|
|
31
|
-
if not api_key:
|
|
32
|
-
raise MsLinearError(
|
|
33
|
-
code=ErrorCode.MISSING_API_KEY,
|
|
34
|
-
message="LINEAR_API_KEY environment variable not set",
|
|
35
|
-
suggestions=[
|
|
36
|
-
"Set LINEAR_API_KEY in your shell: export LINEAR_API_KEY='lin_api_...'",
|
|
37
|
-
"Get your API key from Linear Settings > API > Personal API keys",
|
|
38
|
-
],
|
|
39
|
-
)
|
|
40
|
-
return api_key
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def load_config(config_path: Path | None = None) -> LinearConfig:
|
|
44
|
-
"""Load configuration from .linear.json.
|
|
45
|
-
|
|
46
|
-
Searches upward from current directory if no path specified.
|
|
47
|
-
"""
|
|
48
|
-
if config_path is None:
|
|
49
|
-
config_path = find_config_file()
|
|
50
|
-
|
|
51
|
-
if config_path is None or not config_path.exists():
|
|
52
|
-
raise MsLinearError(
|
|
53
|
-
code=ErrorCode.MISSING_CONFIG,
|
|
54
|
-
message=".linear.json not found in project",
|
|
55
|
-
suggestions=[
|
|
56
|
-
"Create .linear.json in your project root",
|
|
57
|
-
'Required fields: {"teamId": "uuid", "projectId": "uuid"}',
|
|
58
|
-
"Find IDs from Linear URLs or use `ms-linear states` to discover team",
|
|
59
|
-
],
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
try:
|
|
63
|
-
with open(config_path) as f:
|
|
64
|
-
data = json.load(f)
|
|
65
|
-
except json.JSONDecodeError as e:
|
|
66
|
-
raise MsLinearError(
|
|
67
|
-
code=ErrorCode.INVALID_CONFIG,
|
|
68
|
-
message=f"Invalid JSON in .linear.json: {e}",
|
|
69
|
-
suggestions=["Check .linear.json for syntax errors"],
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
if "teamId" not in data:
|
|
73
|
-
raise MsLinearError(
|
|
74
|
-
code=ErrorCode.INVALID_CONFIG,
|
|
75
|
-
message="teamId is required in .linear.json",
|
|
76
|
-
suggestions=['Add "teamId": "your-team-uuid" to .linear.json'],
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
return LinearConfig(
|
|
80
|
-
team_id=data["teamId"],
|
|
81
|
-
project_id=data.get("projectId"),
|
|
82
|
-
default_priority=data.get("defaultPriority", 3),
|
|
83
|
-
default_labels=data.get("defaultLabels", []),
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def find_config_file() -> Path | None:
|
|
88
|
-
"""Find .linear.json by searching upward from original working directory."""
|
|
89
|
-
# Use MS_LINEAR_CWD if set (passed from wrapper script), otherwise fall back to cwd
|
|
90
|
-
start_dir = os.environ.get("MS_LINEAR_CWD")
|
|
91
|
-
current = Path(start_dir) if start_dir else Path.cwd()
|
|
92
|
-
|
|
93
|
-
while current != current.parent:
|
|
94
|
-
config_path = current / ".linear.json"
|
|
95
|
-
if config_path.exists():
|
|
96
|
-
return config_path
|
|
97
|
-
current = current.parent
|
|
98
|
-
|
|
99
|
-
# Check root
|
|
100
|
-
root_config = current / ".linear.json"
|
|
101
|
-
if root_config.exists():
|
|
102
|
-
return root_config
|
|
103
|
-
|
|
104
|
-
return None
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
"""Error codes and error handling."""
|
|
2
|
-
|
|
3
|
-
from enum import Enum
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class ErrorCode(str, Enum):
|
|
7
|
-
"""Error codes for CLI responses."""
|
|
8
|
-
|
|
9
|
-
MISSING_API_KEY = "MISSING_API_KEY"
|
|
10
|
-
MISSING_CONFIG = "MISSING_CONFIG"
|
|
11
|
-
INVALID_CONFIG = "INVALID_CONFIG"
|
|
12
|
-
ISSUE_NOT_FOUND = "ISSUE_NOT_FOUND"
|
|
13
|
-
STATE_NOT_FOUND = "STATE_NOT_FOUND"
|
|
14
|
-
PROJECT_NOT_FOUND = "PROJECT_NOT_FOUND"
|
|
15
|
-
API_ERROR = "API_ERROR"
|
|
16
|
-
RATE_LIMITED = "RATE_LIMITED"
|
|
17
|
-
NETWORK_ERROR = "NETWORK_ERROR"
|
|
18
|
-
INVALID_RESPONSE = "INVALID_RESPONSE"
|
|
19
|
-
INVALID_INPUT = "INVALID_INPUT"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class MsLinearError(Exception):
|
|
23
|
-
"""Base exception for ms-linear errors."""
|
|
24
|
-
|
|
25
|
-
def __init__(self, code: ErrorCode, message: str, suggestions: list[str] | None = None):
|
|
26
|
-
self.code = code
|
|
27
|
-
self.message = message
|
|
28
|
-
self.suggestions = suggestions or []
|
|
29
|
-
super().__init__(message)
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
"""JSON output formatting."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
from ms_linear.errors import MsLinearError
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def format_success(
|
|
10
|
-
command: str,
|
|
11
|
-
result: dict[str, Any],
|
|
12
|
-
metadata: dict[str, Any] | None = None,
|
|
13
|
-
) -> dict[str, Any]:
|
|
14
|
-
"""Format successful response."""
|
|
15
|
-
response: dict[str, Any] = {
|
|
16
|
-
"success": True,
|
|
17
|
-
"command": command,
|
|
18
|
-
"result": result,
|
|
19
|
-
}
|
|
20
|
-
if metadata:
|
|
21
|
-
response["metadata"] = metadata
|
|
22
|
-
return response
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def format_error(command: str, error: MsLinearError) -> dict[str, Any]:
|
|
26
|
-
"""Format error response."""
|
|
27
|
-
error_dict: dict[str, Any] = {
|
|
28
|
-
"code": error.code.value,
|
|
29
|
-
"message": error.message,
|
|
30
|
-
}
|
|
31
|
-
if error.suggestions:
|
|
32
|
-
error_dict["suggestions"] = error.suggestions
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
"success": False,
|
|
36
|
-
"command": command,
|
|
37
|
-
"error": error_dict,
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def output_json(data: dict[str, Any], pretty: bool = False) -> str:
|
|
42
|
-
"""Convert data to JSON string."""
|
|
43
|
-
if pretty:
|
|
44
|
-
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
45
|
-
return json.dumps(data, ensure_ascii=False)
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "ms-linear"
|
|
3
|
-
version = "1.0.0"
|
|
4
|
-
description = "CLI tool for Mindsystem Linear integration - create, update, and manage issues"
|
|
5
|
-
requires-python = ">=3.10"
|
|
6
|
-
dependencies = [
|
|
7
|
-
"typer>=0.9.0",
|
|
8
|
-
"httpx>=0.25.0",
|
|
9
|
-
]
|
|
10
|
-
|
|
11
|
-
[project.scripts]
|
|
12
|
-
ms-linear = "ms_linear.cli:app"
|
|
13
|
-
|
|
14
|
-
[build-system]
|
|
15
|
-
requires = ["hatchling"]
|
|
16
|
-
build-backend = "hatchling.build"
|