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
kctl_linear/__init__.py
ADDED
kctl_linear/__main__.py
ADDED
kctl_linear/cli.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Main CLI entry point for kctl-linear."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from kctl_lib import KctlError, handle_cli_error
|
|
10
|
+
|
|
11
|
+
from kctl_linear import __version__
|
|
12
|
+
from kctl_linear.commands.config_cmd import app as config_app
|
|
13
|
+
from kctl_linear.commands.cycles import app as cycles_app
|
|
14
|
+
from kctl_linear.commands.dashboard import app as dashboard_app
|
|
15
|
+
from kctl_linear.commands.health import app as health_app
|
|
16
|
+
from kctl_linear.commands.issues import app as issues_app
|
|
17
|
+
from kctl_linear.commands.labels import app as labels_app
|
|
18
|
+
from kctl_linear.commands.projects import app as projects_app
|
|
19
|
+
from kctl_linear.commands.teams import app as teams_app
|
|
20
|
+
from kctl_linear.commands.users import app as users_app
|
|
21
|
+
from kctl_linear.core.callbacks import AppContext
|
|
22
|
+
from kctl_linear.core.plugins import discover_and_load_plugins
|
|
23
|
+
from kctl_linear.commands.skill_cmd import app as skill_app
|
|
24
|
+
from kctl_linear.commands.doctor_cmd import app as doctor_app
|
|
25
|
+
from kctl_lib.self_update import notify_if_outdated
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def version_callback(value: bool) -> None:
|
|
29
|
+
if value:
|
|
30
|
+
typer.echo(f"kctl-linear {__version__}")
|
|
31
|
+
raise typer.Exit()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
name="kctl-linear",
|
|
36
|
+
help="Linear project management",
|
|
37
|
+
no_args_is_help=True,
|
|
38
|
+
rich_markup_mode="rich",
|
|
39
|
+
pretty_exceptions_enable=False,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.callback()
|
|
44
|
+
def main(
|
|
45
|
+
ctx: typer.Context,
|
|
46
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
47
|
+
quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
|
|
48
|
+
profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
|
|
49
|
+
format: Annotated[str, typer.Option("--format", "-f", help="Output format: pretty, json, csv, yaml")] = "pretty",
|
|
50
|
+
no_header: Annotated[bool, typer.Option("--no-header", help="Omit header row in CSV output")] = False,
|
|
51
|
+
version: Annotated[
|
|
52
|
+
bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
|
|
53
|
+
] = False,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Linear project management."""
|
|
56
|
+
ctx.ensure_object(dict)
|
|
57
|
+
ctx.obj = AppContext(
|
|
58
|
+
json_mode=json_output,
|
|
59
|
+
quiet=quiet,
|
|
60
|
+
profile=profile,
|
|
61
|
+
format=format,
|
|
62
|
+
no_header=no_header,
|
|
63
|
+
)
|
|
64
|
+
notify_if_outdated(ctx.obj.output, "kctl-linear", __version__)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Command group registration
|
|
68
|
+
app.add_typer(config_app, name="config")
|
|
69
|
+
app.add_typer(health_app, name="health")
|
|
70
|
+
app.add_typer(dashboard_app, name="dashboard")
|
|
71
|
+
app.add_typer(issues_app, name="issues")
|
|
72
|
+
app.add_typer(cycles_app, name="cycles")
|
|
73
|
+
app.add_typer(projects_app, name="projects")
|
|
74
|
+
app.add_typer(teams_app, name="teams")
|
|
75
|
+
app.add_typer(labels_app, name="labels")
|
|
76
|
+
app.add_typer(users_app, name="users")
|
|
77
|
+
app.add_typer(skill_app, name="skill", hidden=True)
|
|
78
|
+
app.add_typer(doctor_app, name="doctor")
|
|
79
|
+
|
|
80
|
+
# Load third-party plugins via entry points
|
|
81
|
+
discover_and_load_plugins(app)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command("self-update")
|
|
85
|
+
def self_update_cmd(ctx: typer.Context) -> None:
|
|
86
|
+
"""Check for updates and upgrade kctl-linear."""
|
|
87
|
+
actx = ctx.obj
|
|
88
|
+
out = actx.output
|
|
89
|
+
|
|
90
|
+
from kctl_lib.self_update import check_update
|
|
91
|
+
from kctl_lib.self_update import update as do_update
|
|
92
|
+
|
|
93
|
+
latest = check_update("kctl-linear", __version__)
|
|
94
|
+
if latest:
|
|
95
|
+
out.info(f"Updating to {latest}...")
|
|
96
|
+
do_update("kctl-linear")
|
|
97
|
+
out.success(f"Updated to {latest}")
|
|
98
|
+
else:
|
|
99
|
+
out.success("Already up to date")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def completions(
|
|
104
|
+
shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
|
|
105
|
+
install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Generate or install shell completions."""
|
|
108
|
+
from kctl_lib.completions import get_completion_script, install_completions
|
|
109
|
+
|
|
110
|
+
if install:
|
|
111
|
+
path = install_completions("kctl-linear", shell)
|
|
112
|
+
if path:
|
|
113
|
+
typer.echo(f"Completions installed to {path}")
|
|
114
|
+
else:
|
|
115
|
+
typer.echo(f"Could not install completions for {shell}", err=True)
|
|
116
|
+
raise typer.Exit(code=1)
|
|
117
|
+
else:
|
|
118
|
+
script = get_completion_script("kctl-linear", shell)
|
|
119
|
+
typer.echo(script)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _run() -> None:
|
|
123
|
+
"""Entry point with error handling."""
|
|
124
|
+
try:
|
|
125
|
+
app()
|
|
126
|
+
except KctlError as e:
|
|
127
|
+
handle_cli_error(e)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
_run()
|
|
File without changes
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Config profile 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.config import (
|
|
11
|
+
SERVICE_KEY,
|
|
12
|
+
ServiceConfig,
|
|
13
|
+
get_all_services_in_profile,
|
|
14
|
+
get_profile_names,
|
|
15
|
+
get_service_config,
|
|
16
|
+
remove_profile,
|
|
17
|
+
resolve_active_profile_name,
|
|
18
|
+
set_default_profile,
|
|
19
|
+
set_service_config,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(help="Profile and configuration management.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command()
|
|
26
|
+
def init(ctx: typer.Context) -> None:
|
|
27
|
+
"""Interactive config setup."""
|
|
28
|
+
actx: AppContext = ctx.obj
|
|
29
|
+
out = actx.output
|
|
30
|
+
profile_name = typer.prompt("Profile name", default="default")
|
|
31
|
+
api_key = typer.prompt("Linear API key", hide_input=True)
|
|
32
|
+
default_team = typer.prompt("Default team key (e.g., KOD)", default="")
|
|
33
|
+
svc = ServiceConfig(api_key=api_key, default_team=default_team)
|
|
34
|
+
set_service_config(profile_name, svc)
|
|
35
|
+
set_default_profile(profile_name)
|
|
36
|
+
out.success(f"Config saved to profile '{profile_name}'")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command()
|
|
40
|
+
def add(
|
|
41
|
+
ctx: typer.Context,
|
|
42
|
+
name: Annotated[str, typer.Argument(help="Profile name")],
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Add a new config profile."""
|
|
45
|
+
actx: AppContext = ctx.obj
|
|
46
|
+
out = actx.output
|
|
47
|
+
api_key = typer.prompt("Linear API key", hide_input=True)
|
|
48
|
+
default_team = typer.prompt("Default team key (e.g., KOD)", default="")
|
|
49
|
+
svc = ServiceConfig(api_key=api_key, default_team=default_team)
|
|
50
|
+
set_service_config(name, svc)
|
|
51
|
+
out.success(f"Profile '{name}' added")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command()
|
|
55
|
+
def use(
|
|
56
|
+
ctx: typer.Context,
|
|
57
|
+
name: Annotated[str, typer.Argument(help="Profile name to activate")],
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Switch active config profile."""
|
|
60
|
+
actx: AppContext = ctx.obj
|
|
61
|
+
out = actx.output
|
|
62
|
+
available = get_profile_names()
|
|
63
|
+
if name not in available:
|
|
64
|
+
out.error(f"Profile '{name}' not found. Available: {', '.join(available)}")
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
set_default_profile(name)
|
|
67
|
+
out.success(f"Switched to profile '{name}'")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command()
|
|
71
|
+
def show(ctx: typer.Context) -> None:
|
|
72
|
+
"""Show current configuration."""
|
|
73
|
+
actx: AppContext = ctx.obj
|
|
74
|
+
out = actx.output
|
|
75
|
+
active = resolve_active_profile_name(actx.profile)
|
|
76
|
+
profiles = get_profile_names()
|
|
77
|
+
if out.json_mode:
|
|
78
|
+
out.raw_json({"active_profile": active, "profiles": {n: get_all_services_in_profile(n) for n in profiles}})
|
|
79
|
+
return
|
|
80
|
+
out.header("Configuration")
|
|
81
|
+
out.kv("Active profile", active)
|
|
82
|
+
for name in profiles:
|
|
83
|
+
marker = " (active)" if name == active else ""
|
|
84
|
+
out.header(f"Profile: {name}{marker}")
|
|
85
|
+
services = get_all_services_in_profile(name)
|
|
86
|
+
for svc_name, svc_data in services.items():
|
|
87
|
+
out.text(f" [bold]{svc_name}:[/bold]")
|
|
88
|
+
if isinstance(svc_data, dict):
|
|
89
|
+
for k, v in svc_data.items():
|
|
90
|
+
out.kv(f" {k}", str(v))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command()
|
|
94
|
+
def validate(ctx: typer.Context) -> None:
|
|
95
|
+
"""Validate current config completeness."""
|
|
96
|
+
actx: AppContext = ctx.obj
|
|
97
|
+
out = actx.output
|
|
98
|
+
active = resolve_active_profile_name(actx.profile)
|
|
99
|
+
svc = get_service_config(active)
|
|
100
|
+
issues: list[str] = []
|
|
101
|
+
if not svc.api_key:
|
|
102
|
+
issues.append("api_key is not set")
|
|
103
|
+
if not svc.default_team:
|
|
104
|
+
issues.append("default_team is not set (optional but recommended)")
|
|
105
|
+
if out.json_mode:
|
|
106
|
+
out.raw_json({"profile": active, "valid": not any("api_key" in i for i in issues), "issues": issues})
|
|
107
|
+
return
|
|
108
|
+
if any("api_key" in i for i in issues):
|
|
109
|
+
out.error(f"Profile '{active}' has issues:")
|
|
110
|
+
for issue in issues:
|
|
111
|
+
out.text(f" - {issue}")
|
|
112
|
+
raise typer.Exit(1)
|
|
113
|
+
if issues:
|
|
114
|
+
out.warn(f"Profile '{active}' has warnings:")
|
|
115
|
+
for issue in issues:
|
|
116
|
+
out.text(f" - {issue}")
|
|
117
|
+
else:
|
|
118
|
+
out.success(f"Profile '{active}' is valid")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command()
|
|
122
|
+
def remove(
|
|
123
|
+
ctx: typer.Context,
|
|
124
|
+
name: Annotated[str, typer.Argument(help="Profile name to remove")],
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Remove a config profile."""
|
|
127
|
+
actx: AppContext = ctx.obj
|
|
128
|
+
out = actx.output
|
|
129
|
+
available = get_profile_names()
|
|
130
|
+
if name not in available:
|
|
131
|
+
out.error(f"Profile '{name}' not found")
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
remove_profile(name)
|
|
134
|
+
out.success(f"Profile '{name}' removed")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command("set")
|
|
138
|
+
def set_(
|
|
139
|
+
ctx: typer.Context,
|
|
140
|
+
key: Annotated[str, typer.Argument(help="Config key")],
|
|
141
|
+
value: Annotated[str, typer.Argument(help="Config value")],
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Set a single config value."""
|
|
144
|
+
actx: AppContext = ctx.obj
|
|
145
|
+
out = actx.output
|
|
146
|
+
active = resolve_active_profile_name(actx.profile)
|
|
147
|
+
svc = get_service_config(active)
|
|
148
|
+
if key not in ServiceConfig.model_fields:
|
|
149
|
+
out.error(f"Unknown key '{key}'. Valid: {', '.join(ServiceConfig.model_fields)}")
|
|
150
|
+
raise typer.Exit(1)
|
|
151
|
+
data = svc.model_dump()
|
|
152
|
+
data[key] = value
|
|
153
|
+
set_service_config(active, ServiceConfig(**data))
|
|
154
|
+
out.success(f"Set {key}={value} in profile '{active}'")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.command()
|
|
158
|
+
def profiles(ctx: typer.Context) -> None:
|
|
159
|
+
"""List all config profiles."""
|
|
160
|
+
actx: AppContext = ctx.obj
|
|
161
|
+
out = actx.output
|
|
162
|
+
active = resolve_active_profile_name(actx.profile)
|
|
163
|
+
names = get_profile_names()
|
|
164
|
+
if out.json_mode:
|
|
165
|
+
out.raw_json({"profiles": names, "active": active})
|
|
166
|
+
return
|
|
167
|
+
rows = [[name, "active" if name == active else ""] for name in names]
|
|
168
|
+
out.table("Profiles", [("Name", "cyan"), ("Status", "green")], rows)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.command()
|
|
172
|
+
def current(ctx: typer.Context) -> None:
|
|
173
|
+
"""Show active profile and resolved context."""
|
|
174
|
+
actx: AppContext = ctx.obj
|
|
175
|
+
out = actx.output
|
|
176
|
+
active = resolve_active_profile_name(actx.profile)
|
|
177
|
+
svc = get_service_config(active)
|
|
178
|
+
if out.json_mode:
|
|
179
|
+
out.raw_json({"profile": active, **svc.model_dump()})
|
|
180
|
+
return
|
|
181
|
+
fields = [(k, str(v) or "(not set)") for k, v in svc.model_dump().items()]
|
|
182
|
+
sections = [("Active Profile", [("Name", active)] + fields)]
|
|
183
|
+
out.detail("Current Config", sections)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.command()
|
|
187
|
+
def test(ctx: typer.Context) -> None:
|
|
188
|
+
"""Test API connection with current configuration."""
|
|
189
|
+
actx: AppContext = ctx.obj
|
|
190
|
+
out = actx.output
|
|
191
|
+
active = resolve_active_profile_name(actx.profile)
|
|
192
|
+
out.info(f"Testing profile '{active}' \u2192 {SERVICE_KEY}")
|
|
193
|
+
try:
|
|
194
|
+
data = actx.client.query("{ viewer { id name email } }")
|
|
195
|
+
viewer = data.get("viewer", {})
|
|
196
|
+
name = viewer.get("name", "unknown")
|
|
197
|
+
email = viewer.get("email", "")
|
|
198
|
+
out.success(f"Connected \u2014 authenticated as '{name}' ({email})")
|
|
199
|
+
except Exception as e:
|
|
200
|
+
out.error(f"Connection failed: {e}")
|
|
201
|
+
raise typer.Exit(1) from e
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Cycle (sprint) 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 (
|
|
11
|
+
CYCLE_CURRENT_QUERY,
|
|
12
|
+
CYCLE_SHOW_QUERY,
|
|
13
|
+
CYCLES_LIST_QUERY,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(help="Cycle (sprint) management.")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def current(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
team: Annotated[str | None, typer.Option("--team", "-t", help="Team key")] = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Show current active cycle with progress and issues."""
|
|
25
|
+
actx: AppContext = ctx.obj
|
|
26
|
+
out = actx.output
|
|
27
|
+
|
|
28
|
+
team_key = team or actx.default_team
|
|
29
|
+
data = actx.client.query(CYCLE_CURRENT_QUERY, {"teamKey": team_key})
|
|
30
|
+
cycles = data.get("cycles", {}).get("nodes", [])
|
|
31
|
+
|
|
32
|
+
if not cycles:
|
|
33
|
+
out.info("No active cycle")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
cycle = cycles[0]
|
|
37
|
+
|
|
38
|
+
if out.json_mode:
|
|
39
|
+
out.raw_json(cycle)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
progress = cycle.get("progress", 0)
|
|
43
|
+
progress_pct = f"{progress * 100:.0f}%" if isinstance(progress, (int, float)) else str(progress)
|
|
44
|
+
|
|
45
|
+
sections = [
|
|
46
|
+
(
|
|
47
|
+
"Current Cycle",
|
|
48
|
+
[
|
|
49
|
+
("Name", cycle.get("name") or f"Cycle {cycle.get('number', '?')}"),
|
|
50
|
+
("Progress", progress_pct),
|
|
51
|
+
("Starts", (cycle.get("startsAt") or "")[:10]),
|
|
52
|
+
("Ends", (cycle.get("endsAt") or "")[:10]),
|
|
53
|
+
],
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
out.detail("Active Cycle", sections)
|
|
57
|
+
|
|
58
|
+
issues = cycle.get("issues", {}).get("nodes", [])
|
|
59
|
+
if issues:
|
|
60
|
+
rows = [
|
|
61
|
+
[
|
|
62
|
+
issue.get("identifier", ""),
|
|
63
|
+
issue.get("title", "")[:50],
|
|
64
|
+
str(issue.get("priority", "-")),
|
|
65
|
+
issue.get("state", {}).get("name", ""),
|
|
66
|
+
(issue.get("assignee") or {}).get("name", ""),
|
|
67
|
+
]
|
|
68
|
+
for issue in issues
|
|
69
|
+
]
|
|
70
|
+
out.table(
|
|
71
|
+
f"Cycle Issues ({len(issues)})",
|
|
72
|
+
[("ID", "cyan"), ("Title", "white"), ("P", "yellow"), ("State", "green"), ("Assignee", "magenta")],
|
|
73
|
+
rows,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("list")
|
|
78
|
+
def list_(
|
|
79
|
+
ctx: typer.Context,
|
|
80
|
+
team: Annotated[str | None, typer.Option("--team", "-t", help="Team key")] = None,
|
|
81
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 20,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""List past and upcoming cycles."""
|
|
84
|
+
actx: AppContext = ctx.obj
|
|
85
|
+
out = actx.output
|
|
86
|
+
|
|
87
|
+
team_key = team or actx.default_team
|
|
88
|
+
data = actx.client.query(CYCLES_LIST_QUERY, {"teamKey": team_key, "first": limit})
|
|
89
|
+
cycles = data.get("cycles", {}).get("nodes", [])
|
|
90
|
+
|
|
91
|
+
if out.json_mode:
|
|
92
|
+
out.raw_json(cycles)
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
if not cycles:
|
|
96
|
+
out.info("No cycles found")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
rows = [
|
|
100
|
+
[
|
|
101
|
+
str(c.get("number", "")),
|
|
102
|
+
c.get("name") or f"Cycle {c.get('number', '?')}",
|
|
103
|
+
(c.get("startsAt") or "")[:10],
|
|
104
|
+
(c.get("endsAt") or "")[:10],
|
|
105
|
+
f"{(c.get('progress', 0) or 0) * 100:.0f}%",
|
|
106
|
+
]
|
|
107
|
+
for c in cycles
|
|
108
|
+
]
|
|
109
|
+
out.table(
|
|
110
|
+
f"Cycles ({len(cycles)})",
|
|
111
|
+
[("Num", "cyan"), ("Name", "white"), ("Start", "yellow"), ("End", "yellow"), ("Progress", "green")],
|
|
112
|
+
rows,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def show(
|
|
118
|
+
ctx: typer.Context,
|
|
119
|
+
cycle_id: Annotated[str, typer.Argument(help="Cycle ID (UUID)")],
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Show cycle details: scope, completed, remaining issues."""
|
|
122
|
+
actx: AppContext = ctx.obj
|
|
123
|
+
out = actx.output
|
|
124
|
+
|
|
125
|
+
data = actx.client.query(CYCLE_SHOW_QUERY, {"id": cycle_id})
|
|
126
|
+
cycle = data.get("cycle", {})
|
|
127
|
+
|
|
128
|
+
if out.json_mode:
|
|
129
|
+
out.raw_json(cycle)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
progress = cycle.get("progress", 0)
|
|
133
|
+
progress_pct = f"{progress * 100:.0f}%" if isinstance(progress, (int, float)) else str(progress)
|
|
134
|
+
issues = cycle.get("issues", {}).get("nodes", [])
|
|
135
|
+
|
|
136
|
+
sections = [
|
|
137
|
+
(
|
|
138
|
+
"Cycle Details",
|
|
139
|
+
[
|
|
140
|
+
("Name", cycle.get("name") or f"Cycle {cycle.get('number', '?')}"),
|
|
141
|
+
("Number", str(cycle.get("number", ""))),
|
|
142
|
+
("Progress", progress_pct),
|
|
143
|
+
("Starts", (cycle.get("startsAt") or "")[:10]),
|
|
144
|
+
("Ends", (cycle.get("endsAt") or "")[:10]),
|
|
145
|
+
("Total Issues", str(len(issues))),
|
|
146
|
+
],
|
|
147
|
+
),
|
|
148
|
+
]
|
|
149
|
+
out.detail(f"Cycle {cycle.get('number', '?')}", sections)
|
|
150
|
+
|
|
151
|
+
if issues:
|
|
152
|
+
rows = [
|
|
153
|
+
[
|
|
154
|
+
issue.get("identifier", ""),
|
|
155
|
+
issue.get("title", "")[:50],
|
|
156
|
+
issue.get("state", {}).get("name", ""),
|
|
157
|
+
(issue.get("assignee") or {}).get("name", ""),
|
|
158
|
+
str(issue.get("estimate") or "-"),
|
|
159
|
+
]
|
|
160
|
+
for issue in issues
|
|
161
|
+
]
|
|
162
|
+
out.table(
|
|
163
|
+
"",
|
|
164
|
+
[("ID", "cyan"), ("Title", "white"), ("State", "green"), ("Assignee", "magenta"), ("Est", "yellow")],
|
|
165
|
+
rows,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def stats(
|
|
171
|
+
ctx: typer.Context,
|
|
172
|
+
team: Annotated[str | None, typer.Option("--team", "-t", help="Team key")] = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Show velocity trends across recent cycles."""
|
|
175
|
+
actx: AppContext = ctx.obj
|
|
176
|
+
out = actx.output
|
|
177
|
+
|
|
178
|
+
team_key = team or actx.default_team
|
|
179
|
+
data = actx.client.query(CYCLES_LIST_QUERY, {"teamKey": team_key, "first": 10})
|
|
180
|
+
cycles = data.get("cycles", {}).get("nodes", [])
|
|
181
|
+
|
|
182
|
+
if out.json_mode:
|
|
183
|
+
out.raw_json({"cycles": len(cycles), "data": cycles})
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
if not cycles:
|
|
187
|
+
out.info("No cycles found for velocity analysis")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
out.header("Velocity Trends (last 10 cycles)")
|
|
191
|
+
rows = [
|
|
192
|
+
[
|
|
193
|
+
str(c.get("number", "")),
|
|
194
|
+
c.get("name") or f"Cycle {c.get('number', '?')}",
|
|
195
|
+
f"{(c.get('progress', 0) or 0) * 100:.0f}%",
|
|
196
|
+
(c.get("startsAt") or "")[:10],
|
|
197
|
+
(c.get("endsAt") or "")[:10],
|
|
198
|
+
]
|
|
199
|
+
for c in cycles
|
|
200
|
+
]
|
|
201
|
+
out.table(
|
|
202
|
+
"",
|
|
203
|
+
[("Num", "cyan"), ("Name", "white"), ("Completion", "green"), ("Start", "yellow"), ("End", "yellow")],
|
|
204
|
+
rows,
|
|
205
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Dashboard — quick overview of my issues, current cycle, active projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from kctl_linear.core.callbacks import AppContext
|
|
8
|
+
from kctl_linear.core.client import DASHBOARD_QUERY
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Quick overview dashboard.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.callback(invoke_without_command=True)
|
|
14
|
+
def dashboard(ctx: typer.Context) -> None:
|
|
15
|
+
"""Show my issues, current cycle progress, and active projects."""
|
|
16
|
+
actx: AppContext = ctx.obj
|
|
17
|
+
out = actx.output
|
|
18
|
+
client = actx.client
|
|
19
|
+
|
|
20
|
+
variables: dict[str, str | None] = {"teamKey": actx.default_team}
|
|
21
|
+
data = client.query(DASHBOARD_QUERY, variables)
|
|
22
|
+
|
|
23
|
+
viewer = data.get("viewer", {})
|
|
24
|
+
my_issues = viewer.get("assignedIssues", {}).get("nodes", [])
|
|
25
|
+
cycles = data.get("cycles", {}).get("nodes", [])
|
|
26
|
+
projects = data.get("projects", {}).get("nodes", [])
|
|
27
|
+
|
|
28
|
+
if out.json_mode:
|
|
29
|
+
out.raw_json(
|
|
30
|
+
{
|
|
31
|
+
"viewer": {"name": viewer.get("name"), "email": viewer.get("email")},
|
|
32
|
+
"my_issues": my_issues,
|
|
33
|
+
"current_cycle": cycles[0] if cycles else None,
|
|
34
|
+
"active_projects": projects,
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
# My issues
|
|
40
|
+
out.header(f"My Issues ({len(my_issues)})")
|
|
41
|
+
if my_issues:
|
|
42
|
+
rows = [
|
|
43
|
+
[
|
|
44
|
+
issue.get("identifier", ""),
|
|
45
|
+
issue.get("title", "")[:60],
|
|
46
|
+
str(issue.get("priority", "")),
|
|
47
|
+
issue.get("state", {}).get("name", ""),
|
|
48
|
+
]
|
|
49
|
+
for issue in my_issues
|
|
50
|
+
]
|
|
51
|
+
out.table(
|
|
52
|
+
"",
|
|
53
|
+
[("ID", "cyan"), ("Title", "white"), ("Priority", "yellow"), ("State", "green")],
|
|
54
|
+
rows,
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
out.info("No active issues assigned to you")
|
|
58
|
+
|
|
59
|
+
# Current cycle
|
|
60
|
+
if cycles:
|
|
61
|
+
cycle = cycles[0]
|
|
62
|
+
progress = cycle.get("progress", 0)
|
|
63
|
+
progress_pct = f"{progress * 100:.0f}%" if isinstance(progress, (int, float)) else str(progress)
|
|
64
|
+
out.header("Current Cycle")
|
|
65
|
+
out.kv("Name", cycle.get("name") or f"Cycle {cycle.get('number', '?')}")
|
|
66
|
+
out.kv("Progress", progress_pct)
|
|
67
|
+
out.kv("Starts", cycle.get("startsAt", "")[:10])
|
|
68
|
+
out.kv("Ends", cycle.get("endsAt", "")[:10])
|
|
69
|
+
else:
|
|
70
|
+
out.header("Current Cycle")
|
|
71
|
+
out.info("No active cycle")
|
|
72
|
+
|
|
73
|
+
# Active projects
|
|
74
|
+
if projects:
|
|
75
|
+
out.header(f"Active Projects ({len(projects)})")
|
|
76
|
+
rows = [
|
|
77
|
+
[
|
|
78
|
+
proj.get("name", ""),
|
|
79
|
+
f"{(proj.get('progress', 0) or 0) * 100:.0f}%",
|
|
80
|
+
(proj.get("targetDate") or "")[:10],
|
|
81
|
+
]
|
|
82
|
+
for proj in projects
|
|
83
|
+
]
|
|
84
|
+
out.table("", [("Name", "cyan"), ("Progress", "green"), ("Target", "yellow")], rows)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Doctor diagnostic checks for kctl-linear."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_lib.doctor_base import CheckResult, DoctorCheck, run_doctor
|
|
10
|
+
|
|
11
|
+
from kctl_linear.core.callbacks import AppContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ConfigCheck:
|
|
16
|
+
"""Check that configuration is present."""
|
|
17
|
+
|
|
18
|
+
name: str = "Configuration"
|
|
19
|
+
profile: str | None = None
|
|
20
|
+
|
|
21
|
+
def run(self) -> CheckResult:
|
|
22
|
+
try:
|
|
23
|
+
from kctl_linear.core.config import get_service_config, resolve_active_profile_name
|
|
24
|
+
|
|
25
|
+
profile = resolve_active_profile_name(self.profile)
|
|
26
|
+
cfg = get_service_config(profile)
|
|
27
|
+
if not cfg.api_key:
|
|
28
|
+
return CheckResult(
|
|
29
|
+
name=self.name,
|
|
30
|
+
status="fail",
|
|
31
|
+
message="No API key configured",
|
|
32
|
+
fix_command="kctl-linear config init",
|
|
33
|
+
)
|
|
34
|
+
return CheckResult(name=self.name, status="ok", message=f"Profile: {profile}")
|
|
35
|
+
except Exception as e:
|
|
36
|
+
return CheckResult(
|
|
37
|
+
name=self.name,
|
|
38
|
+
status="fail",
|
|
39
|
+
message=str(e),
|
|
40
|
+
fix_command="kctl-linear config init",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
app = typer.Typer(help="Run diagnostic checks.", invoke_without_command=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.callback(invoke_without_command=True)
|
|
48
|
+
def doctor(ctx: typer.Context) -> None:
|
|
49
|
+
"""Run all diagnostic checks."""
|
|
50
|
+
if ctx.invoked_subcommand is not None:
|
|
51
|
+
return
|
|
52
|
+
actx: AppContext = ctx.obj
|
|
53
|
+
out = actx.output
|
|
54
|
+
|
|
55
|
+
checks: list[DoctorCheck] = [ConfigCheck(profile=actx.profile)]
|
|
56
|
+
all_passed = run_doctor(checks, out) # type: ignore[arg-type]
|
|
57
|
+
if not all_passed:
|
|
58
|
+
raise typer.Exit(code=1)
|