taskill 0.1.1__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.
- taskill/__init__.py +20 -0
- taskill/cli.py +253 -0
- taskill/config.py +170 -0
- taskill/core.py +211 -0
- taskill/git_state.py +147 -0
- taskill/providers/__init__.py +37 -0
- taskill/providers/algorithmic.py +155 -0
- taskill/providers/base.py +105 -0
- taskill/providers/openrouter.py +114 -0
- taskill/providers/windsurf_mcp.py +135 -0
- taskill/state.py +42 -0
- taskill/triggers.py +94 -0
- taskill/updaters/__init__.py +6 -0
- taskill/updaters/changelog.py +92 -0
- taskill/updaters/readme.py +74 -0
- taskill/updaters/todo.py +77 -0
- taskill-0.1.1.dist-info/METADATA +194 -0
- taskill-0.1.1.dist-info/RECORD +21 -0
- taskill-0.1.1.dist-info/WHEEL +4 -0
- taskill-0.1.1.dist-info/entry_points.txt +2 -0
- taskill-0.1.1.dist-info/licenses/LICENSE +201 -0
taskill/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""taskill — daily project hygiene automation.
|
|
2
|
+
|
|
3
|
+
Keeps README.md / CHANGELOG.md / TODO.md in sync with reality:
|
|
4
|
+
- reads git log, SUMD.md, SUMR.md, test results, coverage
|
|
5
|
+
- moves completed TODO items to CHANGELOG
|
|
6
|
+
- regenerates README sections that are out of date
|
|
7
|
+
- runs daily (or per metric thresholds) standalone, in CI, or via Ansible
|
|
8
|
+
|
|
9
|
+
Provider chain (first match wins):
|
|
10
|
+
1. Windsurf MCP (when configured — uses your existing JetBrains plugin context)
|
|
11
|
+
2. OpenRouter (LLM via OPENROUTER_API_KEY + LLM_MODEL)
|
|
12
|
+
3. Algorithmic (deterministic fallback: conventional commits + heuristics)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.1"
|
|
16
|
+
|
|
17
|
+
from taskill.config import TaskillConfig, load_config
|
|
18
|
+
from taskill.core import Taskill, TaskillResult
|
|
19
|
+
|
|
20
|
+
__all__ = ["TaskillConfig", "load_config", "Taskill", "TaskillResult", "__version__"]
|
taskill/cli.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Command-line interface for taskill.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
taskill run — execute the pipeline (respects triggers; --force overrides)
|
|
5
|
+
taskill status — show what would happen without running
|
|
6
|
+
taskill init — write a starter taskill.yaml + .env.example
|
|
7
|
+
taskill release — promote [Unreleased] CHANGELOG block to a versioned heading
|
|
8
|
+
taskill clean-todo — wipe TODO.md (use after manually moving items elsewhere)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from taskill import __version__
|
|
22
|
+
from taskill.config import load_config
|
|
23
|
+
from taskill.core import Taskill
|
|
24
|
+
from taskill.updaters.changelog import release_unreleased
|
|
25
|
+
from taskill.updaters.todo import empty_todo
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _setup_logging(verbose: bool) -> None:
|
|
31
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
32
|
+
logging.basicConfig(
|
|
33
|
+
level=level,
|
|
34
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
35
|
+
datefmt="%H:%M:%S",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.group()
|
|
40
|
+
@click.version_option(__version__, prog_name="taskill")
|
|
41
|
+
@click.option("--verbose", "-v", is_flag=True, help="Verbose logging")
|
|
42
|
+
@click.option(
|
|
43
|
+
"--config", "-c", default="taskill.yaml", show_default=True,
|
|
44
|
+
help="Path to taskill.yaml",
|
|
45
|
+
)
|
|
46
|
+
@click.pass_context
|
|
47
|
+
def main(ctx: click.Context, verbose: bool, config: str) -> None:
|
|
48
|
+
"""taskill — keep README/CHANGELOG/TODO honest."""
|
|
49
|
+
_setup_logging(verbose)
|
|
50
|
+
ctx.ensure_object(dict)
|
|
51
|
+
ctx.obj["config_path"] = config
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@main.command()
|
|
55
|
+
@click.option("--force", is_flag=True, help="Run even if triggers say no")
|
|
56
|
+
@click.option("--dry-run", is_flag=True, help="Don't write files or persist state")
|
|
57
|
+
@click.option("--json", "as_json", is_flag=True, help="Output result as JSON")
|
|
58
|
+
@click.pass_context
|
|
59
|
+
def run(ctx: click.Context, force: bool, dry_run: bool, as_json: bool) -> None:
|
|
60
|
+
"""Execute the update pipeline."""
|
|
61
|
+
cfg = load_config(ctx.obj["config_path"])
|
|
62
|
+
if dry_run:
|
|
63
|
+
cfg.dry_run = True
|
|
64
|
+
tk = Taskill(config=cfg)
|
|
65
|
+
result = tk.run(force=force)
|
|
66
|
+
|
|
67
|
+
if as_json:
|
|
68
|
+
click.echo(json.dumps(result.as_dict(), indent=2))
|
|
69
|
+
sys.exit(0 if result.ran or not result.errors else 1)
|
|
70
|
+
|
|
71
|
+
if not result.ran:
|
|
72
|
+
console.print(f"[yellow]✗ Did not run.[/yellow] {result.trigger_eval.summary()}")
|
|
73
|
+
if result.errors:
|
|
74
|
+
for e in result.errors:
|
|
75
|
+
console.print(f" [red]error:[/red] {e}")
|
|
76
|
+
sys.exit(0)
|
|
77
|
+
|
|
78
|
+
console.print(
|
|
79
|
+
f"[green]✓ Ran via [bold]{result.provider_used}[/bold][/green]"
|
|
80
|
+
)
|
|
81
|
+
if result.docs and result.docs.summary:
|
|
82
|
+
console.print(f" [dim]{result.docs.summary}[/dim]")
|
|
83
|
+
|
|
84
|
+
table = Table(show_header=False, box=None, padding=(0, 1))
|
|
85
|
+
table.add_row("Changelog entries", str(len(result.docs.changelog_entries) if result.docs else 0))
|
|
86
|
+
table.add_row("TODO completed", str(len(result.docs.todo_completed) if result.docs else 0))
|
|
87
|
+
table.add_row("TODO new", str(len(result.docs.todo_new) if result.docs else 0))
|
|
88
|
+
table.add_row("Files changed", ", ".join(result.files_changed) or "(none)")
|
|
89
|
+
console.print(table)
|
|
90
|
+
|
|
91
|
+
if result.errors:
|
|
92
|
+
console.print("[dim]Provider fallthrough:[/dim]")
|
|
93
|
+
for e in result.errors:
|
|
94
|
+
console.print(f" [dim]- {e}[/dim]")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@main.command()
|
|
98
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
99
|
+
@click.pass_context
|
|
100
|
+
def status(ctx: click.Context, as_json: bool) -> None:
|
|
101
|
+
"""Show what taskill would do without running it."""
|
|
102
|
+
cfg = load_config(ctx.obj["config_path"])
|
|
103
|
+
tk = Taskill(config=cfg)
|
|
104
|
+
info = tk.status()
|
|
105
|
+
|
|
106
|
+
if as_json:
|
|
107
|
+
click.echo(json.dumps(info, indent=2, default=str))
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
table = Table(title="taskill status", show_header=True, header_style="bold cyan")
|
|
111
|
+
table.add_column("Field"); table.add_column("Value")
|
|
112
|
+
color = "green" if info["would_run"] else "yellow"
|
|
113
|
+
table.add_row("Would run?", f"[{color}]{info['would_run']}[/{color}]")
|
|
114
|
+
table.add_row("HEAD", str(info["head"] or "—")[:12])
|
|
115
|
+
table.add_row("Pending commits", str(info["commits_pending"]))
|
|
116
|
+
table.add_row("Coverage", f"{info['coverage']:.1f}%" if info["coverage"] else "—")
|
|
117
|
+
table.add_row("Failed tests", str(info["failed_tests"]) if info["failed_tests"] is not None else "—")
|
|
118
|
+
table.add_row("Last run", str(info["last_run"] or "never"))
|
|
119
|
+
console.print(table)
|
|
120
|
+
|
|
121
|
+
if info["reasons"]:
|
|
122
|
+
console.print("[green]Reasons to run:[/green]")
|
|
123
|
+
for r in info["reasons"]:
|
|
124
|
+
console.print(f" • {r}")
|
|
125
|
+
if info["skipped_because"]:
|
|
126
|
+
console.print("[yellow]Why not running:[/yellow]")
|
|
127
|
+
for r in info["skipped_because"]:
|
|
128
|
+
console.print(f" • {r}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@main.command()
|
|
132
|
+
@click.option("--force", is_flag=True, help="Overwrite existing taskill.yaml")
|
|
133
|
+
def init(force: bool) -> None:
|
|
134
|
+
"""Generate a starter taskill.yaml + .env.example in the current directory."""
|
|
135
|
+
yml = Path("taskill.yaml")
|
|
136
|
+
env = Path(".env.example")
|
|
137
|
+
|
|
138
|
+
if yml.exists() and not force:
|
|
139
|
+
console.print(f"[yellow]{yml} already exists — use --force to overwrite[/yellow]")
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
|
|
142
|
+
yml.write_text(STARTER_YAML, encoding="utf-8")
|
|
143
|
+
if not env.exists():
|
|
144
|
+
env.write_text(STARTER_ENV, encoding="utf-8")
|
|
145
|
+
|
|
146
|
+
console.print(f"[green]✓[/green] Wrote {yml} and {env}")
|
|
147
|
+
console.print("Next: copy [bold].env.example[/bold] → [bold].env[/bold] and add your OpenRouter key,")
|
|
148
|
+
console.print("then run [cyan]taskill status[/cyan] to inspect, [cyan]taskill run[/cyan] to execute.")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@main.command()
|
|
152
|
+
@click.argument("version")
|
|
153
|
+
@click.option(
|
|
154
|
+
"--changelog", default="CHANGELOG.md", show_default=True,
|
|
155
|
+
help="Path to CHANGELOG.md",
|
|
156
|
+
)
|
|
157
|
+
def release(version: str, changelog: str) -> None:
|
|
158
|
+
"""Promote [Unreleased] section to a versioned [VERSION] heading."""
|
|
159
|
+
p = Path(changelog)
|
|
160
|
+
if release_unreleased(p, version):
|
|
161
|
+
console.print(f"[green]✓[/green] Released v{version} in {p}")
|
|
162
|
+
else:
|
|
163
|
+
console.print(f"[yellow]Nothing to release in {p}[/yellow]")
|
|
164
|
+
sys.exit(1)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@main.command("clean-todo")
|
|
168
|
+
@click.option("--todo", default="TODO.md", show_default=True)
|
|
169
|
+
@click.confirmation_option(prompt="This will erase TODO.md content. Continue?")
|
|
170
|
+
def clean_todo(todo: str) -> None:
|
|
171
|
+
"""Reset TODO.md to an empty header. Use after a release."""
|
|
172
|
+
empty_todo(Path(todo))
|
|
173
|
+
console.print(f"[green]✓[/green] {todo} reset")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ─── starter templates ──────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
STARTER_YAML = """# taskill.yaml — daily project hygiene config
|
|
179
|
+
# Run: taskill status (preview)
|
|
180
|
+
# taskill run (execute when triggers fire)
|
|
181
|
+
# taskill run --force --dry-run (preview the actual output)
|
|
182
|
+
|
|
183
|
+
# Files taskill manages
|
|
184
|
+
files:
|
|
185
|
+
readme: README.md
|
|
186
|
+
changelog: CHANGELOG.md
|
|
187
|
+
todo: TODO.md
|
|
188
|
+
sumd: SUMD.md
|
|
189
|
+
sumr: SUMR.md
|
|
190
|
+
|
|
191
|
+
# When should taskill run? (OR semantics by default — any one true → run)
|
|
192
|
+
triggers:
|
|
193
|
+
min_hours_since_last_run: 24
|
|
194
|
+
min_commits_since_last_run: 1
|
|
195
|
+
changed_files_threshold: 1
|
|
196
|
+
coverage_change_pct: 2.0 # absolute pp; null to disable
|
|
197
|
+
failed_tests_changed: true
|
|
198
|
+
watch_files: [SUMD.md, SUMR.md]
|
|
199
|
+
require_all: false # set true for AND semantics
|
|
200
|
+
|
|
201
|
+
# Provider chain — first one that succeeds wins.
|
|
202
|
+
# Comment out any provider you don't want.
|
|
203
|
+
providers:
|
|
204
|
+
- name: windsurf_mcp
|
|
205
|
+
enabled: true
|
|
206
|
+
options:
|
|
207
|
+
endpoint: stdio
|
|
208
|
+
command: ["windsurf", "mcp", "serve"]
|
|
209
|
+
tool_name: complete
|
|
210
|
+
- name: openrouter
|
|
211
|
+
enabled: true
|
|
212
|
+
options:
|
|
213
|
+
base_url: https://openrouter.ai/api/v1
|
|
214
|
+
temperature: 0.2
|
|
215
|
+
max_tokens: 4096
|
|
216
|
+
timeout: 120
|
|
217
|
+
- name: algorithmic
|
|
218
|
+
enabled: true # always-on safety net; keep this on
|
|
219
|
+
|
|
220
|
+
# Optional: reuse other tools in your stack (loose coupling — subprocess calls)
|
|
221
|
+
reuse:
|
|
222
|
+
pyqual: true
|
|
223
|
+
llx: false
|
|
224
|
+
prefact: false
|
|
225
|
+
|
|
226
|
+
# Where state lives (tracks last run, last sha, last coverage…)
|
|
227
|
+
state_file: .taskill/state.json
|
|
228
|
+
|
|
229
|
+
# CI/VCS integrations — read by `taskill ci-config` (stub for now)
|
|
230
|
+
integrations:
|
|
231
|
+
github: {}
|
|
232
|
+
gitlab: {}
|
|
233
|
+
ansible: {}
|
|
234
|
+
|
|
235
|
+
dry_run: false
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
STARTER_ENV = """# Copy this file to .env and fill in your credentials.
|
|
239
|
+
# .env is loaded automatically by taskill (process env wins on conflict).
|
|
240
|
+
|
|
241
|
+
OPENROUTER_API_KEY=
|
|
242
|
+
|
|
243
|
+
# Default: openrouter/qwen/qwen3-coder-next
|
|
244
|
+
# (taskill auto-strips the openrouter/ prefix for the OpenRouter REST API)
|
|
245
|
+
LLM_MODEL=openrouter/qwen/qwen3-coder-next
|
|
246
|
+
|
|
247
|
+
# Optional: explicit Windsurf MCP endpoint (otherwise auto-discovered)
|
|
248
|
+
# WINDSURF_MCP_ENDPOINT=ws://localhost:7777
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
main()
|
taskill/config.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Config loading: taskill.yaml + .env.
|
|
2
|
+
|
|
3
|
+
Mirrors pyqual's config style (declarative, YAML-first, profile-friendly).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from dotenv import load_dotenv
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ───────────────────────────── data classes ─────────────────────────────
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Triggers:
|
|
20
|
+
"""Conditions that must be met to run an update.
|
|
21
|
+
|
|
22
|
+
All thresholds are OR-ed by default (any one true → run).
|
|
23
|
+
Set `require_all: true` for AND semantics.
|
|
24
|
+
"""
|
|
25
|
+
min_hours_since_last_run: float = 24.0
|
|
26
|
+
min_commits_since_last_run: int = 1
|
|
27
|
+
changed_files_threshold: int = 1
|
|
28
|
+
coverage_change_pct: float | None = None
|
|
29
|
+
failed_tests_changed: bool = False
|
|
30
|
+
require_all: bool = False
|
|
31
|
+
# File-mtime triggers (optional)
|
|
32
|
+
watch_files: list[str] = field(default_factory=lambda: ["SUMD.md", "SUMR.md"])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ProviderConfig:
|
|
37
|
+
"""Single provider configuration."""
|
|
38
|
+
name: str # "windsurf_mcp" | "openrouter" | "algorithmic"
|
|
39
|
+
enabled: bool = True
|
|
40
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class IntegrationConfig:
|
|
45
|
+
"""Optional CI / VCS / orchestrator integration."""
|
|
46
|
+
github: dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
gitlab: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
ansible: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
DEFAULT_FILES = {
|
|
52
|
+
"readme": "README.md",
|
|
53
|
+
"changelog": "CHANGELOG.md",
|
|
54
|
+
"todo": "TODO.md",
|
|
55
|
+
"sumd": "SUMD.md",
|
|
56
|
+
"sumr": "SUMR.md",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
DEFAULT_REUSE = {
|
|
60
|
+
"pyqual": True, # invoke `pyqual report` if available
|
|
61
|
+
"llx": False, # invoke llx for richer LLM context
|
|
62
|
+
"prefact": False, # invoke prefact for refactoring hints
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class TaskillConfig:
|
|
68
|
+
project_root: Path
|
|
69
|
+
files: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_FILES))
|
|
70
|
+
triggers: Triggers = field(default_factory=Triggers)
|
|
71
|
+
providers: list[ProviderConfig] = field(default_factory=list)
|
|
72
|
+
integrations: IntegrationConfig = field(default_factory=IntegrationConfig)
|
|
73
|
+
state_file: str = ".taskill/state.json"
|
|
74
|
+
dry_run: bool = False
|
|
75
|
+
reuse: dict[str, bool] = field(default_factory=lambda: dict(DEFAULT_REUSE))
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def env_model(self) -> str:
|
|
79
|
+
return os.getenv("LLM_MODEL", "openrouter/qwen/qwen3-coder-next")
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def env_api_key(self) -> str | None:
|
|
83
|
+
return os.getenv("OPENROUTER_API_KEY")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ───────────────────────────── loader ─────────────────────────────
|
|
87
|
+
|
|
88
|
+
DEFAULT_PROVIDERS = [
|
|
89
|
+
ProviderConfig(name="windsurf_mcp", enabled=True, options={
|
|
90
|
+
"endpoint": "stdio", # or ws://localhost:PORT
|
|
91
|
+
"tool_name": "windsurf",
|
|
92
|
+
}),
|
|
93
|
+
ProviderConfig(name="openrouter", enabled=True, options={
|
|
94
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
95
|
+
"temperature": 0.2,
|
|
96
|
+
"max_tokens": 4096,
|
|
97
|
+
"timeout": 120,
|
|
98
|
+
}),
|
|
99
|
+
ProviderConfig(name="algorithmic", enabled=True, options={}),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_config(
|
|
104
|
+
path: str | Path = "taskill.yaml",
|
|
105
|
+
project_root: Path | None = None,
|
|
106
|
+
) -> TaskillConfig:
|
|
107
|
+
"""Load config from YAML + .env. Missing YAML → defaults.
|
|
108
|
+
|
|
109
|
+
Resolution order for project_root:
|
|
110
|
+
1. explicit arg
|
|
111
|
+
2. directory of taskill.yaml
|
|
112
|
+
3. cwd
|
|
113
|
+
"""
|
|
114
|
+
path = Path(path)
|
|
115
|
+
if project_root is None:
|
|
116
|
+
project_root = path.parent.resolve() if path.exists() else Path.cwd()
|
|
117
|
+
|
|
118
|
+
# .env has higher priority than process env? No — process env wins (CI-friendly).
|
|
119
|
+
env_path = project_root / ".env"
|
|
120
|
+
if env_path.exists():
|
|
121
|
+
load_dotenv(env_path, override=False)
|
|
122
|
+
|
|
123
|
+
raw: dict[str, Any] = {}
|
|
124
|
+
if path.exists():
|
|
125
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
126
|
+
|
|
127
|
+
# Triggers
|
|
128
|
+
trig_raw = raw.get("triggers", {})
|
|
129
|
+
triggers = Triggers(
|
|
130
|
+
min_hours_since_last_run=trig_raw.get("min_hours_since_last_run", 24.0),
|
|
131
|
+
min_commits_since_last_run=trig_raw.get("min_commits_since_last_run", 1),
|
|
132
|
+
changed_files_threshold=trig_raw.get("changed_files_threshold", 1),
|
|
133
|
+
coverage_change_pct=trig_raw.get("coverage_change_pct"),
|
|
134
|
+
failed_tests_changed=trig_raw.get("failed_tests_changed", False),
|
|
135
|
+
require_all=trig_raw.get("require_all", False),
|
|
136
|
+
watch_files=trig_raw.get("watch_files", ["SUMD.md", "SUMR.md"]),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Providers (preserve order from YAML; if missing, use defaults)
|
|
140
|
+
providers_raw = raw.get("providers")
|
|
141
|
+
if providers_raw:
|
|
142
|
+
providers = [
|
|
143
|
+
ProviderConfig(
|
|
144
|
+
name=p["name"],
|
|
145
|
+
enabled=p.get("enabled", True),
|
|
146
|
+
options=p.get("options", {}),
|
|
147
|
+
)
|
|
148
|
+
for p in providers_raw
|
|
149
|
+
]
|
|
150
|
+
else:
|
|
151
|
+
providers = DEFAULT_PROVIDERS
|
|
152
|
+
|
|
153
|
+
# Integrations
|
|
154
|
+
integ_raw = raw.get("integrations", {})
|
|
155
|
+
integrations = IntegrationConfig(
|
|
156
|
+
github=integ_raw.get("github", {}),
|
|
157
|
+
gitlab=integ_raw.get("gitlab", {}),
|
|
158
|
+
ansible=integ_raw.get("ansible", {}),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return TaskillConfig(
|
|
162
|
+
project_root=Path(raw.get("project_root", project_root)).resolve(),
|
|
163
|
+
files={**DEFAULT_FILES, **raw.get("files", {})},
|
|
164
|
+
triggers=triggers,
|
|
165
|
+
providers=providers,
|
|
166
|
+
integrations=integrations,
|
|
167
|
+
state_file=raw.get("state_file", ".taskill/state.json"),
|
|
168
|
+
dry_run=raw.get("dry_run", False),
|
|
169
|
+
reuse={**DEFAULT_REUSE, **raw.get("reuse", {})},
|
|
170
|
+
)
|
taskill/core.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Taskill core orchestrator.
|
|
2
|
+
|
|
3
|
+
Pipeline:
|
|
4
|
+
1. Load config + state
|
|
5
|
+
2. Collect snapshot (git + filesystem + optional pyqual report)
|
|
6
|
+
3. Evaluate triggers — bail if thresholds not met
|
|
7
|
+
4. Walk provider chain until one succeeds
|
|
8
|
+
5. Apply updates to README/CHANGELOG/TODO
|
|
9
|
+
6. Persist new state
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from taskill.config import TaskillConfig, load_config
|
|
21
|
+
from taskill.git_state import ProjectSnapshot, collect_snapshot
|
|
22
|
+
from taskill.providers import build_chain, GeneratedDocs, ProviderError
|
|
23
|
+
from taskill.state import TaskillState, load_state, save_state
|
|
24
|
+
from taskill.triggers import TriggerEvaluation, evaluate
|
|
25
|
+
from taskill.updaters import update_changelog, update_readme, update_todo
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger("taskill")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TaskillResult:
|
|
32
|
+
ran: bool
|
|
33
|
+
trigger_eval: TriggerEvaluation
|
|
34
|
+
provider_used: str | None = None
|
|
35
|
+
docs: GeneratedDocs | None = None
|
|
36
|
+
files_changed: list[str] = field(default_factory=list)
|
|
37
|
+
errors: list[str] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
def as_dict(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"ran": self.ran,
|
|
42
|
+
"should_have_run": self.trigger_eval.should_run,
|
|
43
|
+
"reasons": self.trigger_eval.reasons,
|
|
44
|
+
"skipped_because": self.trigger_eval.skipped,
|
|
45
|
+
"provider": self.provider_used,
|
|
46
|
+
"summary": self.docs.summary if self.docs else "",
|
|
47
|
+
"changelog_added": len(self.docs.changelog_entries) if self.docs else 0,
|
|
48
|
+
"todo_completed": len(self.docs.todo_completed) if self.docs else 0,
|
|
49
|
+
"todo_new": len(self.docs.todo_new) if self.docs else 0,
|
|
50
|
+
"files_changed": self.files_changed,
|
|
51
|
+
"errors": self.errors,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Taskill:
|
|
56
|
+
def __init__(self, config: TaskillConfig | None = None, config_path: str = "taskill.yaml"):
|
|
57
|
+
self.config = config or load_config(config_path)
|
|
58
|
+
self.state_path = self.config.project_root / self.config.state_file
|
|
59
|
+
self.state = load_state(self.state_path)
|
|
60
|
+
|
|
61
|
+
# ── public API ─────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def run(self, force: bool = False) -> TaskillResult:
|
|
64
|
+
snapshot = self._snapshot()
|
|
65
|
+
trig = evaluate(self.config.triggers, self.state, snapshot, self.config.project_root)
|
|
66
|
+
|
|
67
|
+
if not (trig.should_run or force):
|
|
68
|
+
log.info("Skipping: %s", trig.summary())
|
|
69
|
+
return TaskillResult(ran=False, trigger_eval=trig)
|
|
70
|
+
|
|
71
|
+
# build chain — only currently-available providers
|
|
72
|
+
chain = build_chain(self.config.providers)
|
|
73
|
+
if not chain:
|
|
74
|
+
return TaskillResult(
|
|
75
|
+
ran=False, trigger_eval=trig,
|
|
76
|
+
errors=["No providers configured"],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
context = self._build_context(snapshot)
|
|
80
|
+
docs: GeneratedDocs | None = None
|
|
81
|
+
used: str | None = None
|
|
82
|
+
errors: list[str] = []
|
|
83
|
+
|
|
84
|
+
for provider in chain:
|
|
85
|
+
if not provider.is_available():
|
|
86
|
+
log.info("Provider %s unavailable, skipping", provider.name)
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
log.info("Trying provider: %s", provider.name)
|
|
90
|
+
docs = provider.generate(context)
|
|
91
|
+
used = docs.provider_name or provider.name
|
|
92
|
+
break
|
|
93
|
+
except ProviderError as e:
|
|
94
|
+
msg = f"{provider.name}: {e}"
|
|
95
|
+
log.warning(msg)
|
|
96
|
+
errors.append(msg)
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
if docs is None:
|
|
100
|
+
return TaskillResult(
|
|
101
|
+
ran=False, trigger_eval=trig,
|
|
102
|
+
errors=errors or ["All providers failed or unavailable"],
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# apply updates
|
|
106
|
+
files_changed: list[str] = []
|
|
107
|
+
if not self.config.dry_run:
|
|
108
|
+
files_changed = self._apply(docs, snapshot)
|
|
109
|
+
self._update_state(snapshot)
|
|
110
|
+
save_state(self.state_path, self.state)
|
|
111
|
+
else:
|
|
112
|
+
log.info("Dry-run: not writing files or state")
|
|
113
|
+
|
|
114
|
+
return TaskillResult(
|
|
115
|
+
ran=True,
|
|
116
|
+
trigger_eval=trig,
|
|
117
|
+
provider_used=used,
|
|
118
|
+
docs=docs,
|
|
119
|
+
files_changed=files_changed,
|
|
120
|
+
errors=errors, # non-fatal failures from providers we skipped past
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def status(self) -> dict[str, Any]:
|
|
124
|
+
"""Inspect current trigger state without running anything."""
|
|
125
|
+
snapshot = self._snapshot()
|
|
126
|
+
trig = evaluate(self.config.triggers, self.state, snapshot, self.config.project_root)
|
|
127
|
+
return {
|
|
128
|
+
"would_run": trig.should_run,
|
|
129
|
+
"reasons": trig.reasons,
|
|
130
|
+
"skipped_because": trig.skipped,
|
|
131
|
+
"head": snapshot.head_sha,
|
|
132
|
+
"commits_pending": len(snapshot.commits_since_last_run),
|
|
133
|
+
"coverage": snapshot.coverage_pct,
|
|
134
|
+
"failed_tests": snapshot.failed_tests,
|
|
135
|
+
"last_run": self.state.last_run_iso,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# ── internals ──────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def _snapshot(self) -> ProjectSnapshot:
|
|
141
|
+
return collect_snapshot(
|
|
142
|
+
self.config.project_root,
|
|
143
|
+
self.config.files,
|
|
144
|
+
self.state.last_commit_sha,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _build_context(self, snapshot: ProjectSnapshot) -> dict[str, Any]:
|
|
148
|
+
root = self.config.project_root
|
|
149
|
+
|
|
150
|
+
def _read(name: str) -> str:
|
|
151
|
+
p = root / self.config.files.get(name, f"{name.upper()}.md")
|
|
152
|
+
return p.read_text(encoding="utf-8") if p.exists() else ""
|
|
153
|
+
|
|
154
|
+
ctx: dict[str, Any] = {
|
|
155
|
+
"snapshot": snapshot,
|
|
156
|
+
"existing_readme": _read("readme"),
|
|
157
|
+
"existing_changelog": _read("changelog"),
|
|
158
|
+
"existing_todo": _read("todo"),
|
|
159
|
+
"sumd": snapshot.sumd_text,
|
|
160
|
+
"sumr": snapshot.sumr_text,
|
|
161
|
+
"project_name": root.name,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# optional: enrich with pyqual report (loose coupling — subprocess)
|
|
165
|
+
if self.config.reuse.get("pyqual"):
|
|
166
|
+
ctx["pyqual_report"] = self._maybe_pyqual_report()
|
|
167
|
+
|
|
168
|
+
return ctx
|
|
169
|
+
|
|
170
|
+
def _maybe_pyqual_report(self) -> dict[str, Any] | None:
|
|
171
|
+
"""Run `pyqual report --json` if pyqual is on PATH. Tolerate failure."""
|
|
172
|
+
try:
|
|
173
|
+
res = subprocess.run(
|
|
174
|
+
["pyqual", "report", "--json"],
|
|
175
|
+
cwd=self.config.project_root,
|
|
176
|
+
capture_output=True, text=True, timeout=30, check=False,
|
|
177
|
+
)
|
|
178
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
179
|
+
return json.loads(res.stdout)
|
|
180
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError):
|
|
181
|
+
pass
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def _apply(self, docs: GeneratedDocs, snapshot: ProjectSnapshot) -> list[str]:
|
|
185
|
+
root = self.config.project_root
|
|
186
|
+
changed: list[str] = []
|
|
187
|
+
|
|
188
|
+
cl = root / self.config.files["changelog"]
|
|
189
|
+
if update_changelog(cl, docs.changelog_entries):
|
|
190
|
+
changed.append(str(cl.relative_to(root)))
|
|
191
|
+
|
|
192
|
+
td = root / self.config.files["todo"]
|
|
193
|
+
if update_todo(td, docs.todo_completed, docs.todo_new):
|
|
194
|
+
changed.append(str(td.relative_to(root)))
|
|
195
|
+
|
|
196
|
+
rm = root / self.config.files["readme"]
|
|
197
|
+
if update_readme(rm, snapshot, docs.summary):
|
|
198
|
+
changed.append(str(rm.relative_to(root)))
|
|
199
|
+
|
|
200
|
+
return changed
|
|
201
|
+
|
|
202
|
+
def _update_state(self, snapshot: ProjectSnapshot) -> None:
|
|
203
|
+
self.state.stamp()
|
|
204
|
+
self.state.last_commit_sha = snapshot.head_sha
|
|
205
|
+
self.state.last_coverage_pct = snapshot.coverage_pct
|
|
206
|
+
self.state.last_failed_tests = snapshot.failed_tests
|
|
207
|
+
self.state.last_sumd_hash = snapshot.sumd_hash
|
|
208
|
+
for rel in self.config.triggers.watch_files:
|
|
209
|
+
p = self.config.project_root / rel
|
|
210
|
+
if p.exists():
|
|
211
|
+
self.state.file_mtimes[rel] = p.stat().st_mtime
|