kctl-linear 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kctl_linear/__init__.py +3 -0
- kctl_linear/__main__.py +5 -0
- kctl_linear/cli.py +131 -0
- kctl_linear/commands/__init__.py +0 -0
- kctl_linear/commands/config_cmd.py +201 -0
- kctl_linear/commands/cycles.py +205 -0
- kctl_linear/commands/dashboard.py +84 -0
- kctl_linear/commands/doctor_cmd.py +58 -0
- kctl_linear/commands/health.py +37 -0
- kctl_linear/commands/issues.py +305 -0
- kctl_linear/commands/labels.py +91 -0
- kctl_linear/commands/projects.py +110 -0
- kctl_linear/commands/skill_cmd.py +76 -0
- kctl_linear/commands/teams.py +95 -0
- kctl_linear/commands/users.py +69 -0
- kctl_linear/core/__init__.py +0 -0
- kctl_linear/core/callbacks.py +44 -0
- kctl_linear/core/client.py +533 -0
- kctl_linear/core/config.py +54 -0
- kctl_linear/core/exceptions.py +21 -0
- kctl_linear/core/plugins.py +13 -0
- kctl_linear-0.2.0.dist-info/METADATA +17 -0
- kctl_linear-0.2.0.dist-info/RECORD +25 -0
- kctl_linear-0.2.0.dist-info/WHEEL +4 -0
- kctl_linear-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Health check — verify API connectivity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from kctl_linear.core.callbacks import AppContext
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="API health check.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.callback(invoke_without_command=True)
|
|
13
|
+
def health(ctx: typer.Context) -> None:
|
|
14
|
+
"""Check Linear API connectivity and show current user info."""
|
|
15
|
+
actx: AppContext = ctx.obj
|
|
16
|
+
out = actx.output
|
|
17
|
+
client = actx.client
|
|
18
|
+
|
|
19
|
+
viewer = client.viewer()
|
|
20
|
+
|
|
21
|
+
if out.json_mode:
|
|
22
|
+
out.raw_json({"status": "ok", "viewer": viewer})
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
sections = [
|
|
26
|
+
("API Status", [("Status", "Connected"), ("Endpoint", "https://api.linear.app/graphql")]),
|
|
27
|
+
(
|
|
28
|
+
"Authenticated User",
|
|
29
|
+
[
|
|
30
|
+
("Name", viewer.get("name", "")),
|
|
31
|
+
("Email", viewer.get("email", "")),
|
|
32
|
+
("Admin", str(viewer.get("admin", False))),
|
|
33
|
+
("Active", str(viewer.get("active", True))),
|
|
34
|
+
],
|
|
35
|
+
),
|
|
36
|
+
]
|
|
37
|
+
out.detail("Linear Health", sections)
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Issue management commands — daily use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_linear.core.callbacks import AppContext
|
|
10
|
+
from kctl_linear.core.client import (
|
|
11
|
+
COMMENT_CREATE_MUTATION,
|
|
12
|
+
ISSUE_CREATE_MUTATION,
|
|
13
|
+
ISSUE_SEARCH_QUERY,
|
|
14
|
+
ISSUE_SHOW_QUERY,
|
|
15
|
+
ISSUE_UPDATE_MUTATION,
|
|
16
|
+
ISSUES_LIST_QUERY,
|
|
17
|
+
TEAM_BY_KEY_QUERY,
|
|
18
|
+
USER_BY_NAME_QUERY,
|
|
19
|
+
WORKFLOW_STATE_QUERY,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(help="Issue management.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resolve_team_id(ctx: AppContext, team_key: str) -> str:
|
|
26
|
+
"""Resolve a team key (e.g., 'KOD') to its UUID."""
|
|
27
|
+
data = ctx.client.query(TEAM_BY_KEY_QUERY, {"key": team_key})
|
|
28
|
+
nodes = data.get("teams", {}).get("nodes", [])
|
|
29
|
+
if not nodes:
|
|
30
|
+
raise typer.BadParameter(f"Team '{team_key}' not found")
|
|
31
|
+
return nodes[0]["id"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve_user_id(ctx: AppContext, name: str) -> str:
|
|
35
|
+
"""Resolve a user display name to their UUID."""
|
|
36
|
+
data = ctx.client.query(USER_BY_NAME_QUERY, {"name": name})
|
|
37
|
+
nodes = data.get("users", {}).get("nodes", [])
|
|
38
|
+
if not nodes:
|
|
39
|
+
raise typer.BadParameter(f"User '{name}' not found")
|
|
40
|
+
return nodes[0]["id"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_state_id(ctx: AppContext, team_key: str, state_name: str) -> str:
|
|
44
|
+
"""Resolve a workflow state name to its UUID."""
|
|
45
|
+
data = ctx.client.query(WORKFLOW_STATE_QUERY, {"teamKey": team_key, "stateName": state_name})
|
|
46
|
+
nodes = data.get("workflowStates", {}).get("nodes", [])
|
|
47
|
+
if not nodes:
|
|
48
|
+
raise typer.BadParameter(f"State '{state_name}' not found for team '{team_key}'")
|
|
49
|
+
return nodes[0]["id"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("list")
|
|
53
|
+
def list_(
|
|
54
|
+
ctx: typer.Context,
|
|
55
|
+
team: Annotated[str | None, typer.Option("--team", "-t", help="Team key (e.g., KOD)")] = None,
|
|
56
|
+
state: Annotated[str | None, typer.Option("--state", "-s", help="Filter by state name")] = None,
|
|
57
|
+
assignee: Annotated[str | None, typer.Option("--assignee", "-a", help="Filter by assignee ('me' for self)")] = None,
|
|
58
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 50,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""List issues with optional filters."""
|
|
61
|
+
actx: AppContext = ctx.obj
|
|
62
|
+
out = actx.output
|
|
63
|
+
client = actx.client
|
|
64
|
+
|
|
65
|
+
team_key = team or actx.default_team
|
|
66
|
+
variables: dict[str, str | int | None] = {"first": limit}
|
|
67
|
+
|
|
68
|
+
if team_key:
|
|
69
|
+
variables["teamKey"] = team_key
|
|
70
|
+
if state:
|
|
71
|
+
variables["state"] = state
|
|
72
|
+
|
|
73
|
+
# Resolve 'me' to viewer ID
|
|
74
|
+
if assignee == "me":
|
|
75
|
+
viewer = client.viewer()
|
|
76
|
+
variables["assigneeId"] = viewer["id"]
|
|
77
|
+
elif assignee:
|
|
78
|
+
variables["assigneeId"] = _resolve_user_id(actx, assignee)
|
|
79
|
+
|
|
80
|
+
data = client.query(ISSUES_LIST_QUERY, variables)
|
|
81
|
+
issues = data.get("issues", {}).get("nodes", [])
|
|
82
|
+
|
|
83
|
+
if out.json_mode:
|
|
84
|
+
out.raw_json(issues)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
if not issues:
|
|
88
|
+
out.info("No issues found")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
rows = [
|
|
92
|
+
[
|
|
93
|
+
issue.get("identifier", ""),
|
|
94
|
+
issue.get("title", "")[:55],
|
|
95
|
+
str(issue.get("priority", "-")),
|
|
96
|
+
issue.get("state", {}).get("name", ""),
|
|
97
|
+
(issue.get("assignee") or {}).get("name", "unassigned"),
|
|
98
|
+
]
|
|
99
|
+
for issue in issues
|
|
100
|
+
]
|
|
101
|
+
out.table(
|
|
102
|
+
f"Issues ({len(issues)})",
|
|
103
|
+
[("ID", "cyan"), ("Title", "white"), ("P", "yellow"), ("State", "green"), ("Assignee", "magenta")],
|
|
104
|
+
rows,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def show(
|
|
110
|
+
ctx: typer.Context,
|
|
111
|
+
issue_id: Annotated[str, typer.Argument(help="Issue ID (UUID or identifier like KOD-123)")],
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Show issue details, comments, and history."""
|
|
114
|
+
actx: AppContext = ctx.obj
|
|
115
|
+
out = actx.output
|
|
116
|
+
|
|
117
|
+
data = actx.client.query(ISSUE_SHOW_QUERY, {"id": issue_id})
|
|
118
|
+
issue = data.get("issue", {})
|
|
119
|
+
|
|
120
|
+
if out.json_mode:
|
|
121
|
+
out.raw_json(issue)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
labels = [label["name"] for label in issue.get("labels", {}).get("nodes", [])]
|
|
125
|
+
sections = [
|
|
126
|
+
(
|
|
127
|
+
"Issue",
|
|
128
|
+
[
|
|
129
|
+
("Identifier", issue.get("identifier", "")),
|
|
130
|
+
("Title", issue.get("title", "")),
|
|
131
|
+
("State", issue.get("state", {}).get("name", "")),
|
|
132
|
+
("Priority", issue.get("priorityLabel", "")),
|
|
133
|
+
("Estimate", str(issue.get("estimate") or "-")),
|
|
134
|
+
("Assignee", (issue.get("assignee") or {}).get("name", "unassigned")),
|
|
135
|
+
("Team", (issue.get("team") or {}).get("name", "")),
|
|
136
|
+
("Project", (issue.get("project") or {}).get("name", "-")),
|
|
137
|
+
("Cycle", (issue.get("cycle") or {}).get("name", "-")),
|
|
138
|
+
("Labels", ", ".join(labels) if labels else "-"),
|
|
139
|
+
("URL", issue.get("url", "")),
|
|
140
|
+
],
|
|
141
|
+
),
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
if issue.get("description"):
|
|
145
|
+
sections.append(("Description", [("", issue["description"][:500])]))
|
|
146
|
+
|
|
147
|
+
comments = issue.get("comments", {}).get("nodes", [])
|
|
148
|
+
if comments:
|
|
149
|
+
comment_rows = [
|
|
150
|
+
(f"{c.get('user', {}).get('name', '?')} ({c.get('createdAt', '')[:10]})", c.get("body", "")[:200])
|
|
151
|
+
for c in comments[:10]
|
|
152
|
+
]
|
|
153
|
+
sections.append(("Comments", comment_rows))
|
|
154
|
+
|
|
155
|
+
out.detail(issue.get("identifier", "Issue"), sections)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command()
|
|
159
|
+
def create(
|
|
160
|
+
ctx: typer.Context,
|
|
161
|
+
title: Annotated[str, typer.Option("--title", help="Issue title")],
|
|
162
|
+
team: Annotated[str | None, typer.Option("--team", "-t", help="Team key")] = None,
|
|
163
|
+
description: Annotated[str | None, typer.Option("--desc", "-d", help="Issue description")] = None,
|
|
164
|
+
priority: Annotated[
|
|
165
|
+
int | None, typer.Option("--priority", "-p", help="Priority 0-4 (0=none, 1=urgent, 4=low)")
|
|
166
|
+
] = None,
|
|
167
|
+
assignee: Annotated[str | None, typer.Option("--assignee", "-a", help="Assignee name ('me' for self)")] = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Create a new issue."""
|
|
170
|
+
actx: AppContext = ctx.obj
|
|
171
|
+
out = actx.output
|
|
172
|
+
|
|
173
|
+
team_key = team or actx.default_team
|
|
174
|
+
if not team_key:
|
|
175
|
+
out.error("Team key required. Use --team or set default_team in config.")
|
|
176
|
+
raise typer.Exit(1)
|
|
177
|
+
|
|
178
|
+
team_id = _resolve_team_id(actx, team_key)
|
|
179
|
+
variables: dict[str, str | int | None] = {"teamId": team_id, "title": title}
|
|
180
|
+
|
|
181
|
+
if description:
|
|
182
|
+
variables["description"] = description
|
|
183
|
+
if priority is not None:
|
|
184
|
+
variables["priority"] = priority
|
|
185
|
+
if assignee == "me":
|
|
186
|
+
variables["assigneeId"] = actx.client.viewer()["id"]
|
|
187
|
+
elif assignee:
|
|
188
|
+
variables["assigneeId"] = _resolve_user_id(actx, assignee)
|
|
189
|
+
|
|
190
|
+
data = actx.client.query(ISSUE_CREATE_MUTATION, variables)
|
|
191
|
+
result = data.get("issueCreate", {})
|
|
192
|
+
issue = result.get("issue", {})
|
|
193
|
+
|
|
194
|
+
if out.json_mode:
|
|
195
|
+
out.raw_json(result)
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
out.success(f"Created {issue.get('identifier', '?')}: {issue.get('title', '')}")
|
|
199
|
+
out.kv("URL", issue.get("url", ""))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command()
|
|
203
|
+
def update(
|
|
204
|
+
ctx: typer.Context,
|
|
205
|
+
issue_id: Annotated[str, typer.Argument(help="Issue ID")],
|
|
206
|
+
state: Annotated[str | None, typer.Option("--state", "-s", help="New state name")] = None,
|
|
207
|
+
assignee: Annotated[str | None, typer.Option("--assignee", "-a", help="New assignee")] = None,
|
|
208
|
+
priority: Annotated[int | None, typer.Option("--priority", "-p", help="New priority (0-4)")] = None,
|
|
209
|
+
title: Annotated[str | None, typer.Option("--title", help="New title")] = None,
|
|
210
|
+
description: Annotated[str | None, typer.Option("--desc", "-d", help="New description")] = None,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Update an existing issue."""
|
|
213
|
+
actx: AppContext = ctx.obj
|
|
214
|
+
out = actx.output
|
|
215
|
+
|
|
216
|
+
variables: dict[str, str | int | None] = {"id": issue_id}
|
|
217
|
+
|
|
218
|
+
if state:
|
|
219
|
+
team_key = actx.default_team
|
|
220
|
+
if not team_key:
|
|
221
|
+
out.error("default_team must be configured to resolve state names")
|
|
222
|
+
raise typer.Exit(1)
|
|
223
|
+
variables["stateId"] = _resolve_state_id(actx, team_key, state)
|
|
224
|
+
|
|
225
|
+
if assignee == "me":
|
|
226
|
+
variables["assigneeId"] = actx.client.viewer()["id"]
|
|
227
|
+
elif assignee:
|
|
228
|
+
variables["assigneeId"] = _resolve_user_id(actx, assignee)
|
|
229
|
+
|
|
230
|
+
if priority is not None:
|
|
231
|
+
variables["priority"] = priority
|
|
232
|
+
if title:
|
|
233
|
+
variables["title"] = title
|
|
234
|
+
if description:
|
|
235
|
+
variables["description"] = description
|
|
236
|
+
|
|
237
|
+
data = actx.client.query(ISSUE_UPDATE_MUTATION, variables)
|
|
238
|
+
result = data.get("issueUpdate", {})
|
|
239
|
+
issue = result.get("issue", {})
|
|
240
|
+
|
|
241
|
+
if out.json_mode:
|
|
242
|
+
out.raw_json(result)
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
out.success(f"Updated {issue.get('identifier', '?')}: {issue.get('title', '')}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@app.command()
|
|
249
|
+
def comment(
|
|
250
|
+
ctx: typer.Context,
|
|
251
|
+
issue_id: Annotated[str, typer.Argument(help="Issue ID")],
|
|
252
|
+
body: Annotated[str, typer.Option("--body", "-b", help="Comment text")],
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Add a comment to an issue."""
|
|
255
|
+
actx: AppContext = ctx.obj
|
|
256
|
+
out = actx.output
|
|
257
|
+
|
|
258
|
+
data = actx.client.query(COMMENT_CREATE_MUTATION, {"issueId": issue_id, "body": body})
|
|
259
|
+
result = data.get("commentCreate", {})
|
|
260
|
+
|
|
261
|
+
if out.json_mode:
|
|
262
|
+
out.raw_json(result)
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
if result.get("success"):
|
|
266
|
+
out.success("Comment added")
|
|
267
|
+
else:
|
|
268
|
+
out.error("Failed to add comment")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@app.command()
|
|
272
|
+
def search(
|
|
273
|
+
ctx: typer.Context,
|
|
274
|
+
query: Annotated[str, typer.Argument(help="Search query")],
|
|
275
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 50,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Full-text search for issues."""
|
|
278
|
+
actx: AppContext = ctx.obj
|
|
279
|
+
out = actx.output
|
|
280
|
+
|
|
281
|
+
data = actx.client.query(ISSUE_SEARCH_QUERY, {"term": query, "first": limit})
|
|
282
|
+
issues = data.get("searchIssues", {}).get("nodes", [])
|
|
283
|
+
|
|
284
|
+
if out.json_mode:
|
|
285
|
+
out.raw_json(issues)
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
if not issues:
|
|
289
|
+
out.info(f"No issues matching '{query}'")
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
rows = [
|
|
293
|
+
[
|
|
294
|
+
issue.get("identifier", ""),
|
|
295
|
+
issue.get("title", "")[:55],
|
|
296
|
+
issue.get("state", {}).get("name", ""),
|
|
297
|
+
(issue.get("assignee") or {}).get("name", ""),
|
|
298
|
+
]
|
|
299
|
+
for issue in issues
|
|
300
|
+
]
|
|
301
|
+
out.table(
|
|
302
|
+
f"Search: '{query}' ({len(issues)} results)",
|
|
303
|
+
[("ID", "cyan"), ("Title", "white"), ("State", "green"), ("Assignee", "magenta")],
|
|
304
|
+
rows,
|
|
305
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Label management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_linear.core.callbacks import AppContext
|
|
10
|
+
from kctl_linear.core.client import LABEL_CREATE_MUTATION, LABELS_LIST_QUERY, TEAM_BY_KEY_QUERY
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Label management.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resolve_team_id(ctx: AppContext, team_key: str) -> str:
|
|
16
|
+
"""Resolve a team key to its UUID."""
|
|
17
|
+
data = ctx.client.query(TEAM_BY_KEY_QUERY, {"key": team_key})
|
|
18
|
+
nodes = data.get("teams", {}).get("nodes", [])
|
|
19
|
+
if not nodes:
|
|
20
|
+
raise typer.BadParameter(f"Team '{team_key}' not found")
|
|
21
|
+
return nodes[0]["id"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("list")
|
|
25
|
+
def list_(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
team: Annotated[str | None, typer.Option("--team", "-t", help="Team key")] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""List all labels, optionally filtered by team."""
|
|
30
|
+
actx: AppContext = ctx.obj
|
|
31
|
+
out = actx.output
|
|
32
|
+
|
|
33
|
+
team_key = team or actx.default_team
|
|
34
|
+
variables: dict[str, str | None] = {}
|
|
35
|
+
if team_key:
|
|
36
|
+
variables["teamKey"] = team_key
|
|
37
|
+
|
|
38
|
+
data = actx.client.query(LABELS_LIST_QUERY, variables)
|
|
39
|
+
labels = data.get("issueLabels", {}).get("nodes", [])
|
|
40
|
+
|
|
41
|
+
if out.json_mode:
|
|
42
|
+
out.raw_json(labels)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
if not labels:
|
|
46
|
+
out.info("No labels found")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
rows = [
|
|
50
|
+
[
|
|
51
|
+
lbl.get("name", ""),
|
|
52
|
+
lbl.get("color", ""),
|
|
53
|
+
(lbl.get("parent") or {}).get("name", "-"),
|
|
54
|
+
]
|
|
55
|
+
for lbl in labels
|
|
56
|
+
]
|
|
57
|
+
out.table(
|
|
58
|
+
f"Labels ({len(labels)})",
|
|
59
|
+
[("Name", "cyan"), ("Color", "yellow"), ("Parent", "white")],
|
|
60
|
+
rows,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command()
|
|
65
|
+
def create(
|
|
66
|
+
ctx: typer.Context,
|
|
67
|
+
name: Annotated[str, typer.Argument(help="Label name")],
|
|
68
|
+
color: Annotated[str | None, typer.Option("--color", "-c", help="Hex color (e.g., #ff0000)")] = None,
|
|
69
|
+
team: Annotated[str | None, typer.Option("--team", "-t", help="Team key (scoped label)")] = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Create a new label."""
|
|
72
|
+
actx: AppContext = ctx.obj
|
|
73
|
+
out = actx.output
|
|
74
|
+
|
|
75
|
+
variables: dict[str, str | None] = {"name": name}
|
|
76
|
+
if color:
|
|
77
|
+
variables["color"] = color
|
|
78
|
+
|
|
79
|
+
team_key = team or actx.default_team
|
|
80
|
+
if team_key:
|
|
81
|
+
variables["teamId"] = _resolve_team_id(actx, team_key)
|
|
82
|
+
|
|
83
|
+
data = actx.client.query(LABEL_CREATE_MUTATION, variables)
|
|
84
|
+
result = data.get("issueLabelCreate", {})
|
|
85
|
+
label = result.get("issueLabel", {})
|
|
86
|
+
|
|
87
|
+
if out.json_mode:
|
|
88
|
+
out.raw_json(result)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
out.success(f"Created label '{label.get('name', name)}' ({label.get('color', '')})")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Project tracking commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_linear.core.callbacks import AppContext
|
|
10
|
+
from kctl_linear.core.client import PROJECT_SHOW_QUERY, PROJECTS_LIST_QUERY
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Project tracking.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("list")
|
|
16
|
+
def list_(ctx: typer.Context) -> None:
|
|
17
|
+
"""List active projects with progress."""
|
|
18
|
+
actx: AppContext = ctx.obj
|
|
19
|
+
out = actx.output
|
|
20
|
+
|
|
21
|
+
data = actx.client.query(PROJECTS_LIST_QUERY)
|
|
22
|
+
projects = data.get("projects", {}).get("nodes", [])
|
|
23
|
+
|
|
24
|
+
if out.json_mode:
|
|
25
|
+
out.raw_json(projects)
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
if not projects:
|
|
29
|
+
out.info("No projects found")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
rows = [
|
|
33
|
+
[
|
|
34
|
+
p.get("name", ""),
|
|
35
|
+
p.get("state", ""),
|
|
36
|
+
f"{(p.get('progress', 0) or 0) * 100:.0f}%",
|
|
37
|
+
(p.get("lead") or {}).get("name", "-"),
|
|
38
|
+
(p.get("targetDate") or "-")[:10],
|
|
39
|
+
]
|
|
40
|
+
for p in projects
|
|
41
|
+
]
|
|
42
|
+
out.table(
|
|
43
|
+
f"Projects ({len(projects)})",
|
|
44
|
+
[("Name", "cyan"), ("State", "green"), ("Progress", "yellow"), ("Lead", "magenta"), ("Target", "white")],
|
|
45
|
+
rows,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command()
|
|
50
|
+
def show(
|
|
51
|
+
ctx: typer.Context,
|
|
52
|
+
project_id: Annotated[str, typer.Argument(help="Project ID (UUID)")],
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Show project details, milestones, and member issues."""
|
|
55
|
+
actx: AppContext = ctx.obj
|
|
56
|
+
out = actx.output
|
|
57
|
+
|
|
58
|
+
data = actx.client.query(PROJECT_SHOW_QUERY, {"id": project_id})
|
|
59
|
+
project = data.get("project", {})
|
|
60
|
+
|
|
61
|
+
if out.json_mode:
|
|
62
|
+
out.raw_json(project)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
teams = [t.get("name", "") for t in project.get("teams", {}).get("nodes", [])]
|
|
66
|
+
members = [m.get("name", "") for m in project.get("members", {}).get("nodes", [])]
|
|
67
|
+
milestones = project.get("projectMilestones", {}).get("nodes", [])
|
|
68
|
+
issues = project.get("issues", {}).get("nodes", [])
|
|
69
|
+
|
|
70
|
+
sections = [
|
|
71
|
+
(
|
|
72
|
+
"Project",
|
|
73
|
+
[
|
|
74
|
+
("Name", project.get("name", "")),
|
|
75
|
+
("State", project.get("state", "")),
|
|
76
|
+
("Progress", f"{(project.get('progress', 0) or 0) * 100:.0f}%"),
|
|
77
|
+
("Lead", (project.get("lead") or {}).get("name", "-")),
|
|
78
|
+
("Start", (project.get("startDate") or "-")[:10]),
|
|
79
|
+
("Target", (project.get("targetDate") or "-")[:10]),
|
|
80
|
+
("Teams", ", ".join(teams) if teams else "-"),
|
|
81
|
+
("Members", ", ".join(members) if members else "-"),
|
|
82
|
+
("URL", project.get("url", "")),
|
|
83
|
+
],
|
|
84
|
+
),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
if project.get("description"):
|
|
88
|
+
sections.append(("Description", [("", project["description"][:500])]))
|
|
89
|
+
|
|
90
|
+
if milestones:
|
|
91
|
+
ms_rows = [(m.get("name", ""), (m.get("targetDate") or "-")[:10]) for m in milestones]
|
|
92
|
+
sections.append(("Milestones", ms_rows))
|
|
93
|
+
|
|
94
|
+
out.detail(project.get("name", "Project"), sections)
|
|
95
|
+
|
|
96
|
+
if issues:
|
|
97
|
+
rows = [
|
|
98
|
+
[
|
|
99
|
+
i.get("identifier", ""),
|
|
100
|
+
i.get("title", "")[:50],
|
|
101
|
+
i.get("state", {}).get("name", ""),
|
|
102
|
+
(i.get("assignee") or {}).get("name", ""),
|
|
103
|
+
]
|
|
104
|
+
for i in issues[:20]
|
|
105
|
+
]
|
|
106
|
+
out.table(
|
|
107
|
+
f"Issues ({len(issues)})",
|
|
108
|
+
[("ID", "cyan"), ("Title", "white"), ("State", "green"), ("Assignee", "magenta")],
|
|
109
|
+
rows,
|
|
110
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Skill generation for Claude Code integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from kctl_linear.core.callbacks import AppContext
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Claude Code skill management.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def generate(
|
|
17
|
+
ctx: typer.Context,
|
|
18
|
+
output: Annotated[str, typer.Option("--output", "-o", help="Output directory")] = "",
|
|
19
|
+
install: Annotated[bool, typer.Option("--install", help="Install to ~/.claude/skills/")] = False,
|
|
20
|
+
check: Annotated[bool, typer.Option("--check", help="Check if SKILL.md is stale (exit 1 if stale)")] = False,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Auto-generate SKILL.md from CLI command registry.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
kctl-linear skill generate
|
|
26
|
+
kctl-linear skill generate --install
|
|
27
|
+
kctl-linear skill generate --check
|
|
28
|
+
"""
|
|
29
|
+
actx: AppContext = ctx.obj
|
|
30
|
+
out = actx.output
|
|
31
|
+
from kctl_lib.skill_generator import check_stale, generate_skill
|
|
32
|
+
|
|
33
|
+
from kctl_linear.cli import app as cli_app
|
|
34
|
+
|
|
35
|
+
skill_name = "linear-admin"
|
|
36
|
+
description = "Linear project tracking administration via kctl-linear CLI"
|
|
37
|
+
|
|
38
|
+
# Canonical in-repo skill dir — source of SKILL.extra.md regardless
|
|
39
|
+
# of where output goes. Without this, --install writes to
|
|
40
|
+
# ~/.claude/skills/ and the handwritten runbook vanishes.
|
|
41
|
+
cli_root = Path(__file__).resolve().parents[3]
|
|
42
|
+
source_dir = cli_root / "skills" / skill_name
|
|
43
|
+
|
|
44
|
+
# Determine output directory
|
|
45
|
+
if output:
|
|
46
|
+
output_dir = Path(output)
|
|
47
|
+
elif install:
|
|
48
|
+
output_dir = Path.home() / ".claude" / "skills" / skill_name
|
|
49
|
+
else:
|
|
50
|
+
output_dir = source_dir
|
|
51
|
+
|
|
52
|
+
# Check-only mode
|
|
53
|
+
if check:
|
|
54
|
+
skill_file = output_dir / "SKILL.md"
|
|
55
|
+
is_stale, reason = check_stale(cli_app, skill_file)
|
|
56
|
+
if is_stale:
|
|
57
|
+
out.warn(f"SKILL.md is stale: {reason}")
|
|
58
|
+
out.info("Run: kctl-linear skill generate")
|
|
59
|
+
raise typer.Exit(1)
|
|
60
|
+
out.success(f"SKILL.md is up to date: {reason}")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
# Extra content always lives at the in-repo location.
|
|
64
|
+
extra = source_dir / "SKILL.extra.md"
|
|
65
|
+
|
|
66
|
+
generate_skill(
|
|
67
|
+
cli_app,
|
|
68
|
+
"kctl-linear",
|
|
69
|
+
skill_name,
|
|
70
|
+
description,
|
|
71
|
+
output_dir=output_dir,
|
|
72
|
+
extra_file=extra if extra.exists() else None,
|
|
73
|
+
)
|
|
74
|
+
out.success(f"Generated {output_dir / 'SKILL.md'}")
|
|
75
|
+
if install:
|
|
76
|
+
out.success(f"Installed to ~/.claude/skills/{skill_name}/")
|