tsk-cli 0.1.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.
- tsk_cli/__init__.py +0 -0
- tsk_cli/api.py +75 -0
- tsk_cli/auth.py +133 -0
- tsk_cli/config.py +73 -0
- tsk_cli/list_cmd.py +87 -0
- tsk_cli/main.py +234 -0
- tsk_cli/pull.py +432 -0
- tsk_cli/push.py +246 -0
- tsk_cli/resolve.py +40 -0
- tsk_cli/set_status.py +60 -0
- tsk_cli/show.py +59 -0
- tsk_cli-0.1.0.dist-info/METADATA +72 -0
- tsk_cli-0.1.0.dist-info/RECORD +16 -0
- tsk_cli-0.1.0.dist-info/WHEEL +5 -0
- tsk_cli-0.1.0.dist-info/entry_points.txt +2 -0
- tsk_cli-0.1.0.dist-info/top_level.txt +1 -0
tsk_cli/pull.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tsk pull -- fetch projects and things from the API, write .task/ directory.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from .api import api_get
|
|
16
|
+
from .config import get_default_org
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pull(target_dir: Path | None = None) -> None:
|
|
20
|
+
org = get_default_org()
|
|
21
|
+
if not org:
|
|
22
|
+
click.echo("No default organization set. Run: tsk orgs", err=True)
|
|
23
|
+
raise SystemExit(1)
|
|
24
|
+
|
|
25
|
+
org_id = org["id"]
|
|
26
|
+
org_name = org["name"]
|
|
27
|
+
|
|
28
|
+
click.echo(f"Pulling from org: {org_name}")
|
|
29
|
+
|
|
30
|
+
projects: list[dict[str, Any]] = api_get(f"/api/projects/?organization={org_id}") # type: ignore[assignment]
|
|
31
|
+
|
|
32
|
+
if not projects:
|
|
33
|
+
click.echo("No projects found.")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
root = (target_dir or Path.cwd()) / ".task"
|
|
37
|
+
projects_dir = root / "projects"
|
|
38
|
+
snapshot_projects_dir = root / ".snapshot" / "projects"
|
|
39
|
+
|
|
40
|
+
# Clean previous pull (user-facing + baseline for tsk push)
|
|
41
|
+
import shutil
|
|
42
|
+
|
|
43
|
+
if projects_dir.exists():
|
|
44
|
+
shutil.rmtree(projects_dir)
|
|
45
|
+
if snapshot_projects_dir.exists():
|
|
46
|
+
shutil.rmtree(snapshot_projects_dir)
|
|
47
|
+
projects_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
snapshot_projects_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
total_things = 0
|
|
51
|
+
all_things: dict[str, list[dict[str, Any]]] = {}
|
|
52
|
+
|
|
53
|
+
for proj in projects:
|
|
54
|
+
abbr: str = proj["abbreviation"]
|
|
55
|
+
proj_dir = projects_dir / abbr
|
|
56
|
+
things_dir = proj_dir / "things"
|
|
57
|
+
things_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
snap_proj_dir = snapshot_projects_dir / abbr
|
|
60
|
+
snap_things_dir = snap_proj_dir / "things"
|
|
61
|
+
snap_things_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
proj_meta: dict[str, Any] = {
|
|
64
|
+
"id": proj["id"],
|
|
65
|
+
"name": proj["name"],
|
|
66
|
+
"abbreviation": abbr,
|
|
67
|
+
"organization": org_id,
|
|
68
|
+
"thing_count": proj.get("thing_count", 0),
|
|
69
|
+
"thing_count_by_status": proj.get("thing_count_by_status", {}),
|
|
70
|
+
}
|
|
71
|
+
if proj.get("github_repo"):
|
|
72
|
+
proj_meta["github_repo"] = proj["github_repo"]
|
|
73
|
+
proj_dir.joinpath("project.json").write_text(
|
|
74
|
+
json.dumps(proj_meta, indent=2) + "\n"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
things: list[dict[str, Any]] = api_get( # type: ignore[assignment]
|
|
78
|
+
f"/api/things/?organization={org_id}&project={proj['id']}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
all_things[abbr] = things
|
|
82
|
+
for thing in things:
|
|
83
|
+
content = _thing_file_content(abbr, thing)
|
|
84
|
+
things_dir.joinpath(content.filename).write_text(content.text)
|
|
85
|
+
snap_things_dir.joinpath(content.filename).write_text(content.text)
|
|
86
|
+
total_things += 1
|
|
87
|
+
|
|
88
|
+
# Write .task/config.json
|
|
89
|
+
root.joinpath("config.json").write_text(
|
|
90
|
+
json.dumps(
|
|
91
|
+
{
|
|
92
|
+
"org_id": org_id,
|
|
93
|
+
"org_name": org_name,
|
|
94
|
+
"pulled_at": datetime.now(timezone.utc).isoformat(),
|
|
95
|
+
},
|
|
96
|
+
indent=2,
|
|
97
|
+
)
|
|
98
|
+
+ "\n"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Write .task/AGENTS.md for local AI agents
|
|
102
|
+
_write_agents_md(root, org_name, projects, all_things)
|
|
103
|
+
|
|
104
|
+
# Ensure .task is git-ignored
|
|
105
|
+
gitignore = root / ".gitignore"
|
|
106
|
+
if not gitignore.exists():
|
|
107
|
+
gitignore.write_text("*\n")
|
|
108
|
+
|
|
109
|
+
click.echo(
|
|
110
|
+
f"Pulled {len(projects)} project(s), {total_things} thing(s) into .task/"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _write_agents_md(
|
|
115
|
+
root: Path,
|
|
116
|
+
org_name: str,
|
|
117
|
+
projects: list[dict[str, Any]],
|
|
118
|
+
all_things: dict[str, list[dict[str, Any]]],
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Generate .task/AGENTS.md -- instructions for local AI agents."""
|
|
121
|
+
lines: list[str] = []
|
|
122
|
+
w = lines.append
|
|
123
|
+
|
|
124
|
+
w("# TSK Agent Instructions")
|
|
125
|
+
w("")
|
|
126
|
+
w(f"Organization: **{org_name}**")
|
|
127
|
+
w("")
|
|
128
|
+
w("This directory (`.task/`) is managed by the `tsk` CLI. It contains")
|
|
129
|
+
w("projects and things (work items) pulled from [tsk.tools](https://tsk.tools).")
|
|
130
|
+
w("")
|
|
131
|
+
|
|
132
|
+
# ── Workflow (imperative, first thing agents read) ─────────────
|
|
133
|
+
w("## Workflow")
|
|
134
|
+
w("")
|
|
135
|
+
w("Follow these steps when working on a thing:")
|
|
136
|
+
w("")
|
|
137
|
+
w("1. **Before you start**: run `tsk start <ref>` (e.g. `tsk start FE-001`).")
|
|
138
|
+
w(" This sets the thing's status to `in_progress`.")
|
|
139
|
+
w("2. **Every commit**: prefix the message with the thing ref.")
|
|
140
|
+
w(" Example: `git commit -m 'FE-001 fix auth race condition'`.")
|
|
141
|
+
w("3. **Branch names**: include the ref so merged PRs are linked automatically.")
|
|
142
|
+
w(" Example: `git checkout -b FE-001-fix-auth`.")
|
|
143
|
+
w("4. **Code comments**: when you leave a relevant comment in source code,")
|
|
144
|
+
w(" include the ref (e.g. `// FE-001: guard against nil`). These are")
|
|
145
|
+
w(" detected and surfaced as linked code on the thing.")
|
|
146
|
+
w("5. **When finished**: run `tsk done <ref>` or `tsk review <ref>`.")
|
|
147
|
+
w("")
|
|
148
|
+
|
|
149
|
+
# ── How code linking works ────────────────────────────────────
|
|
150
|
+
w("## How code gets linked to a thing")
|
|
151
|
+
w("")
|
|
152
|
+
w("The server automatically scans the project's linked GitHub repo for the")
|
|
153
|
+
w("ref pattern `ABBR-NNN` (case-insensitive). A match in **any** of the")
|
|
154
|
+
w("following places creates a link:")
|
|
155
|
+
w("")
|
|
156
|
+
w("- **Commit messages** -- the most reliable method.")
|
|
157
|
+
w("- **PR titles and branch names** -- caught when the PR is merged.")
|
|
158
|
+
w("- **Source code** -- comments, strings, or any text in tracked files")
|
|
159
|
+
w(" (found via GitHub Code Search).")
|
|
160
|
+
w("")
|
|
161
|
+
w("Good commit messages:")
|
|
162
|
+
w("")
|
|
163
|
+
w("```")
|
|
164
|
+
w("FE-001 fix auth race condition")
|
|
165
|
+
w("FE-001 add loading spinner to dashboard")
|
|
166
|
+
w("```")
|
|
167
|
+
w("")
|
|
168
|
+
w("Bad commit messages (ref is missing -- nothing gets linked):")
|
|
169
|
+
w("")
|
|
170
|
+
w("```")
|
|
171
|
+
w("fix auth race condition")
|
|
172
|
+
w("update dashboard")
|
|
173
|
+
w("```")
|
|
174
|
+
w("")
|
|
175
|
+
|
|
176
|
+
# ── Valid statuses ────────────────────────────────────────────
|
|
177
|
+
w("## Valid statuses")
|
|
178
|
+
w("")
|
|
179
|
+
w("| Status | Meaning |")
|
|
180
|
+
w("|--------|---------|")
|
|
181
|
+
w("| `not_started` | Default. Work has not begun. |")
|
|
182
|
+
w("| `in_progress` | Actively being worked on. |")
|
|
183
|
+
w("| `in_review` | Work is complete, awaiting review. |")
|
|
184
|
+
w("| `done` | Finished. |")
|
|
185
|
+
w("")
|
|
186
|
+
|
|
187
|
+
# ── Updating things ──────────────────────────────────────────
|
|
188
|
+
w("## Updating things")
|
|
189
|
+
w("")
|
|
190
|
+
w("Quick status commands (preferred -- updates the server immediately):")
|
|
191
|
+
w("")
|
|
192
|
+
w("```")
|
|
193
|
+
w("tsk start <ref> # set to in_progress")
|
|
194
|
+
w("tsk review <ref> # set to in_review")
|
|
195
|
+
w("tsk done <ref> # set to done")
|
|
196
|
+
w("```")
|
|
197
|
+
w("")
|
|
198
|
+
w("For other edits (rename, body, arbitrary status):")
|
|
199
|
+
w("")
|
|
200
|
+
w("1. Edit the `.md` file under `.task/projects/<ABBR>/things/`.")
|
|
201
|
+
w("2. Run `tsk push` to upload changes to the server.")
|
|
202
|
+
w("")
|
|
203
|
+
|
|
204
|
+
# ── Thing file format ────────────────────────────────────────
|
|
205
|
+
w("## Thing file format")
|
|
206
|
+
w("")
|
|
207
|
+
w("Each thing is a Markdown file with YAML frontmatter:")
|
|
208
|
+
w("")
|
|
209
|
+
w("```yaml")
|
|
210
|
+
w("---")
|
|
211
|
+
w("id: <uuid> # Read-only. Server primary key.")
|
|
212
|
+
w("ref: COM-001 # Read-only. Human-readable identifier.")
|
|
213
|
+
w("project: <uuid> # Read-only. Parent project ID.")
|
|
214
|
+
w("status: not_started # Editable. See valid statuses above.")
|
|
215
|
+
w("assigned_to: user@email # Read-only via CLI.")
|
|
216
|
+
w("created_at: <iso> # Read-only.")
|
|
217
|
+
w("updated_at: <iso> # Read-only. Updated by server on save.")
|
|
218
|
+
w("---")
|
|
219
|
+
w("# Thing title # Editable. The name of the thing.")
|
|
220
|
+
w("")
|
|
221
|
+
w("Body text here. # Editable. Markdown content.")
|
|
222
|
+
w("```")
|
|
223
|
+
w("")
|
|
224
|
+
|
|
225
|
+
# ── Directory layout ─────────────────────────────────────────
|
|
226
|
+
w("## Directory layout")
|
|
227
|
+
w("")
|
|
228
|
+
w("```")
|
|
229
|
+
w(".task/")
|
|
230
|
+
w(" AGENTS.md # This file")
|
|
231
|
+
w(" config.json # Pull metadata (org, timestamp)")
|
|
232
|
+
w(" projects/")
|
|
233
|
+
w(" <ABBR>/ # One folder per project (e.g. COM, IOS)")
|
|
234
|
+
w(" project.json # Project metadata")
|
|
235
|
+
w(" things/")
|
|
236
|
+
w(" <ABBR>-NNN.md # One file per thing")
|
|
237
|
+
w(" .snapshot/ # Baseline for tsk push (do not edit)")
|
|
238
|
+
w("```")
|
|
239
|
+
w("")
|
|
240
|
+
|
|
241
|
+
# ── Per-project summaries ────────────────────────────────────
|
|
242
|
+
w("## Projects and things")
|
|
243
|
+
w("")
|
|
244
|
+
for proj in projects:
|
|
245
|
+
abbr = proj["abbreviation"]
|
|
246
|
+
w(f"### {proj['name']} (`{abbr}`)")
|
|
247
|
+
w("")
|
|
248
|
+
gh = proj.get("github_repo")
|
|
249
|
+
if gh:
|
|
250
|
+
w(f"GitHub repo: `{gh}` -- <https://github.com/{gh}>")
|
|
251
|
+
w("")
|
|
252
|
+
w(f"When working on a thing from this project, include its ref")
|
|
253
|
+
w(f"(e.g. `{abbr}-001`) at the start of every commit message.")
|
|
254
|
+
w("")
|
|
255
|
+
things = all_things.get(abbr, [])
|
|
256
|
+
if things:
|
|
257
|
+
w("| Ref | Status | Title |")
|
|
258
|
+
w("|-----|--------|-------|")
|
|
259
|
+
for t in things:
|
|
260
|
+
tid = t.get("project_thing_id", 0)
|
|
261
|
+
ref = f"{abbr}-{tid:03d}"
|
|
262
|
+
st = t.get("status", "not_started")
|
|
263
|
+
name = t.get("name", "Untitled")
|
|
264
|
+
w(f"| `{ref}` | `{st}` | {name} |")
|
|
265
|
+
w("")
|
|
266
|
+
else:
|
|
267
|
+
w("No things in this project.")
|
|
268
|
+
w("")
|
|
269
|
+
|
|
270
|
+
# ── CLI quick reference (at the bottom) ──────────────────────
|
|
271
|
+
w("## CLI quick reference")
|
|
272
|
+
w("")
|
|
273
|
+
w("| Command | Description |")
|
|
274
|
+
w("|---------|-------------|")
|
|
275
|
+
w("| `tsk pull` | Fetch latest projects and things into `.task/` |")
|
|
276
|
+
w("| `tsk push [--force]` | Upload local edits to the server |")
|
|
277
|
+
w("| `tsk list [--status S] [--project ABBR]` | List things with optional filters |")
|
|
278
|
+
w("| `tsk show <ref>` | Display a thing's details |")
|
|
279
|
+
w("| `tsk set-status <ref> <status>` | Update status and push immediately |")
|
|
280
|
+
w("| `tsk start <ref>` | Set status to `in_progress` |")
|
|
281
|
+
w("| `tsk done <ref>` | Set status to `done` |")
|
|
282
|
+
w("| `tsk review <ref>` | Set status to `in_review` |")
|
|
283
|
+
w("| `tsk open <ref>` | Open thing in browser at tsk.tools |")
|
|
284
|
+
w("| `tsk status` | Show current config and auth state |")
|
|
285
|
+
w("")
|
|
286
|
+
|
|
287
|
+
root.joinpath("AGENTS.md").write_text("\n".join(lines) + "\n")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass
|
|
291
|
+
class ThingFileContent:
|
|
292
|
+
filename: str
|
|
293
|
+
text: str
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _thing_file_content(abbr: str, thing: dict[str, Any]) -> ThingFileContent:
|
|
297
|
+
"""Build markdown for a single thing (<ABBR>-NNN.md with YAML frontmatter)."""
|
|
298
|
+
tid: int = thing.get("project_thing_id", 0)
|
|
299
|
+
ref = f"{abbr}-{tid:03d}"
|
|
300
|
+
filename = f"{ref}.md"
|
|
301
|
+
|
|
302
|
+
body = _extract_body(thing)
|
|
303
|
+
|
|
304
|
+
frontmatter_lines = [
|
|
305
|
+
"---",
|
|
306
|
+
f"id: {thing['id']}",
|
|
307
|
+
f"ref: {ref}",
|
|
308
|
+
f"project: {thing.get('project', '')}",
|
|
309
|
+
f"status: {thing.get('status', 'not_started')}",
|
|
310
|
+
f"assigned_to: {thing.get('assigned_to_email', '')}",
|
|
311
|
+
f"created_at: {thing.get('created_at', '')}",
|
|
312
|
+
f"updated_at: {thing.get('updated_at', '')}",
|
|
313
|
+
"---",
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
text = "\n".join(frontmatter_lines) + "\n"
|
|
317
|
+
text += f"# {thing.get('name', 'Untitled')}\n\n"
|
|
318
|
+
if body:
|
|
319
|
+
text += body + "\n"
|
|
320
|
+
|
|
321
|
+
return ThingFileContent(filename=filename, text=text)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _extract_body(thing: dict[str, Any]) -> str:
|
|
325
|
+
"""
|
|
326
|
+
Convert body_json (TipTap ProseMirror JSON) to plain markdown-ish text.
|
|
327
|
+
Falls back to the HTML body field stripped of tags.
|
|
328
|
+
"""
|
|
329
|
+
body_json_str = thing.get("body_json", "")
|
|
330
|
+
if body_json_str:
|
|
331
|
+
try:
|
|
332
|
+
doc = json.loads(body_json_str)
|
|
333
|
+
return _prosemirror_to_text(doc)
|
|
334
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
html_body = thing.get("body", "")
|
|
338
|
+
if html_body:
|
|
339
|
+
return _strip_html(html_body)
|
|
340
|
+
|
|
341
|
+
return ""
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _prosemirror_to_text(doc: dict) -> str:
|
|
345
|
+
"""
|
|
346
|
+
Minimal ProseMirror JSON -> markdown converter.
|
|
347
|
+
Handles paragraphs, headings, bullet/ordered lists, task lists, and code blocks.
|
|
348
|
+
"""
|
|
349
|
+
if not isinstance(doc, dict):
|
|
350
|
+
return ""
|
|
351
|
+
content = doc.get("content", [])
|
|
352
|
+
return "\n".join(_render_node(node) for node in content).strip()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _render_node(node: dict, depth: int = 0) -> str:
|
|
356
|
+
ntype = node.get("type", "")
|
|
357
|
+
|
|
358
|
+
if ntype == "text":
|
|
359
|
+
text = node.get("text", "")
|
|
360
|
+
marks = node.get("marks", [])
|
|
361
|
+
for mark in marks:
|
|
362
|
+
mt = mark.get("type", "")
|
|
363
|
+
if mt == "bold":
|
|
364
|
+
text = f"**{text}**"
|
|
365
|
+
elif mt == "italic":
|
|
366
|
+
text = f"*{text}*"
|
|
367
|
+
elif mt == "code":
|
|
368
|
+
text = f"`{text}`"
|
|
369
|
+
return text
|
|
370
|
+
|
|
371
|
+
if ntype == "paragraph":
|
|
372
|
+
inner = _render_children(node)
|
|
373
|
+
return inner + "\n"
|
|
374
|
+
|
|
375
|
+
if ntype == "heading":
|
|
376
|
+
level = node.get("attrs", {}).get("level", 1)
|
|
377
|
+
inner = _render_children(node)
|
|
378
|
+
return "#" * level + " " + inner + "\n"
|
|
379
|
+
|
|
380
|
+
if ntype == "bulletList":
|
|
381
|
+
items = node.get("content", [])
|
|
382
|
+
lines = []
|
|
383
|
+
for item in items:
|
|
384
|
+
text = _render_children(item).strip()
|
|
385
|
+
lines.append(f"{' ' * depth}- {text}")
|
|
386
|
+
return "\n".join(lines) + "\n"
|
|
387
|
+
|
|
388
|
+
if ntype == "orderedList":
|
|
389
|
+
items = node.get("content", [])
|
|
390
|
+
lines = []
|
|
391
|
+
for i, item in enumerate(items, 1):
|
|
392
|
+
text = _render_children(item).strip()
|
|
393
|
+
lines.append(f"{' ' * depth}{i}. {text}")
|
|
394
|
+
return "\n".join(lines) + "\n"
|
|
395
|
+
|
|
396
|
+
if ntype == "taskList":
|
|
397
|
+
items = node.get("content", [])
|
|
398
|
+
lines = []
|
|
399
|
+
for item in items:
|
|
400
|
+
checked = item.get("attrs", {}).get("checked", False)
|
|
401
|
+
marker = "[x]" if checked else "[ ]"
|
|
402
|
+
text = _render_children(item).strip()
|
|
403
|
+
lines.append(f"{' ' * depth}- {marker} {text}")
|
|
404
|
+
return "\n".join(lines) + "\n"
|
|
405
|
+
|
|
406
|
+
if ntype == "codeBlock":
|
|
407
|
+
lang = node.get("attrs", {}).get("language", "")
|
|
408
|
+
inner = _render_children(node)
|
|
409
|
+
return f"```{lang}\n{inner}\n```\n"
|
|
410
|
+
|
|
411
|
+
if ntype == "blockquote":
|
|
412
|
+
inner = _render_children(node)
|
|
413
|
+
return "\n".join(f"> {line}" for line in inner.split("\n")) + "\n"
|
|
414
|
+
|
|
415
|
+
if ntype == "hardBreak":
|
|
416
|
+
return "\n"
|
|
417
|
+
|
|
418
|
+
# Fallback: render children
|
|
419
|
+
return _render_children(node)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _render_children(node: dict) -> str:
|
|
423
|
+
children = node.get("content", [])
|
|
424
|
+
return "".join(_render_node(c) for c in children)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _strip_html(html: str) -> str:
|
|
428
|
+
"""Crude HTML tag stripper for fallback body rendering."""
|
|
429
|
+
import re
|
|
430
|
+
text = re.sub(r"<br\s*/?>", "\n", html)
|
|
431
|
+
text = re.sub(r"<[^>]+>", "", text)
|
|
432
|
+
return text.strip()
|
tsk_cli/push.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tsk push -- upload local .task/ thing edits to the server.
|
|
3
|
+
|
|
4
|
+
Compares each thing file to .task/.snapshot/ (baseline from last pull).
|
|
5
|
+
Uses optimistic concurrency: server updated_at must match snapshot unless --force.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from .api import api_get, api_patch
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ParsedThingFile:
|
|
24
|
+
frontmatter: dict[str, str]
|
|
25
|
+
name: str
|
|
26
|
+
body: str # markdown body below the # title line
|
|
27
|
+
raw: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_thing_markdown(text: str) -> ParsedThingFile:
|
|
31
|
+
"""Parse a thing .md file: YAML frontmatter, # title, body."""
|
|
32
|
+
text = text.replace("\r\n", "\n")
|
|
33
|
+
if not text.startswith("---\n"):
|
|
34
|
+
raise ValueError("Expected YAML frontmatter starting with ---")
|
|
35
|
+
end = text.find("\n---\n", 4)
|
|
36
|
+
if end == -1:
|
|
37
|
+
raise ValueError("Expected closing --- for frontmatter")
|
|
38
|
+
|
|
39
|
+
fm_block = text[4:end]
|
|
40
|
+
rest = text[end + 5 :]
|
|
41
|
+
|
|
42
|
+
frontmatter: dict[str, str] = {}
|
|
43
|
+
for line in fm_block.split("\n"):
|
|
44
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
45
|
+
continue
|
|
46
|
+
if ":" in line:
|
|
47
|
+
k, v = line.split(":", 1)
|
|
48
|
+
frontmatter[k.strip()] = v.strip()
|
|
49
|
+
|
|
50
|
+
lines = rest.split("\n")
|
|
51
|
+
name = "Untitled"
|
|
52
|
+
body_start = 0
|
|
53
|
+
if lines and lines[0].startswith("# "):
|
|
54
|
+
name = lines[0][2:].strip()
|
|
55
|
+
body_start = 1
|
|
56
|
+
elif lines and lines[0].startswith("#"):
|
|
57
|
+
name = lines[0].lstrip("#").strip()
|
|
58
|
+
body_start = 1
|
|
59
|
+
|
|
60
|
+
body_lines = lines[body_start:]
|
|
61
|
+
while body_lines and not body_lines[0].strip():
|
|
62
|
+
body_lines.pop(0)
|
|
63
|
+
body = "\n".join(body_lines).rstrip("\n")
|
|
64
|
+
|
|
65
|
+
return ParsedThingFile(
|
|
66
|
+
frontmatter=frontmatter, name=name, body=body, raw=text
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_thing_markdown(frontmatter: dict[str, str], name: str, body: str) -> str:
|
|
71
|
+
"""Rebuild .md content; frontmatter key order matches pull output."""
|
|
72
|
+
order = [
|
|
73
|
+
"id",
|
|
74
|
+
"ref",
|
|
75
|
+
"project",
|
|
76
|
+
"status",
|
|
77
|
+
"assigned_to",
|
|
78
|
+
"created_at",
|
|
79
|
+
"updated_at",
|
|
80
|
+
]
|
|
81
|
+
lines = ["---"]
|
|
82
|
+
for key in order:
|
|
83
|
+
if key in frontmatter:
|
|
84
|
+
lines.append(f"{key}: {frontmatter[key]}")
|
|
85
|
+
for k, v in sorted(frontmatter.items()):
|
|
86
|
+
if k not in order:
|
|
87
|
+
lines.append(f"{k}: {v}")
|
|
88
|
+
lines.append("---")
|
|
89
|
+
text = "\n".join(lines) + "\n"
|
|
90
|
+
text += f"# {name}\n\n"
|
|
91
|
+
if body:
|
|
92
|
+
text += body + "\n"
|
|
93
|
+
return text
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def markdown_body_to_html(body: str) -> str:
|
|
97
|
+
"""Convert plain / markdown-ish body to HTML the backend can migrate to ProseMirror."""
|
|
98
|
+
body = body.replace("\r\n", "\n").strip()
|
|
99
|
+
if not body:
|
|
100
|
+
return ""
|
|
101
|
+
|
|
102
|
+
parts: list[str] = []
|
|
103
|
+
for para in body.split("\n\n"):
|
|
104
|
+
para = para.strip()
|
|
105
|
+
if not para:
|
|
106
|
+
continue
|
|
107
|
+
inner = html.escape(para).replace("\n", "<br/>")
|
|
108
|
+
parts.append(f"<p>{inner}</p>")
|
|
109
|
+
return "".join(parts)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _semantic_tuple(p: ParsedThingFile) -> tuple[str, str, str]:
|
|
113
|
+
return (p.name, p.frontmatter.get("status", ""), p.body)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def push(*, force: bool = False, target_dir: Path | None = None) -> None:
|
|
117
|
+
root = (target_dir or Path.cwd()) / ".task"
|
|
118
|
+
projects_dir = root / "projects"
|
|
119
|
+
snapshot_root = root / ".snapshot" / "projects"
|
|
120
|
+
|
|
121
|
+
if not projects_dir.is_dir():
|
|
122
|
+
click.echo("No .task/projects directory. Run: tsk pull", err=True)
|
|
123
|
+
raise SystemExit(1)
|
|
124
|
+
|
|
125
|
+
if not snapshot_root.is_dir():
|
|
126
|
+
click.echo(
|
|
127
|
+
"No .task/.snapshot baseline. Run: tsk pull (to refresh snapshots for push)",
|
|
128
|
+
err=True,
|
|
129
|
+
)
|
|
130
|
+
raise SystemExit(1)
|
|
131
|
+
|
|
132
|
+
unchanged = 0
|
|
133
|
+
pushed = 0
|
|
134
|
+
conflicts = 0
|
|
135
|
+
errors = 0
|
|
136
|
+
|
|
137
|
+
for local_path in sorted(projects_dir.glob("**/things/*.md")):
|
|
138
|
+
rel = local_path.relative_to(projects_dir)
|
|
139
|
+
snap_path = snapshot_root / rel
|
|
140
|
+
|
|
141
|
+
if not snap_path.is_file():
|
|
142
|
+
click.echo(
|
|
143
|
+
f" skip {rel}: no snapshot (run tsk pull)", err=True
|
|
144
|
+
)
|
|
145
|
+
errors += 1
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
local_raw = local_path.read_text()
|
|
150
|
+
snap_raw = snap_path.read_text()
|
|
151
|
+
except OSError as e:
|
|
152
|
+
click.echo(f" skip {rel}: read error: {e}", err=True)
|
|
153
|
+
errors += 1
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
if local_raw == snap_raw:
|
|
157
|
+
unchanged += 1
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
local_p = parse_thing_markdown(local_raw)
|
|
162
|
+
snap_p = parse_thing_markdown(snap_raw)
|
|
163
|
+
except ValueError as e:
|
|
164
|
+
click.echo(f" skip {rel}: parse error: {e}", err=True)
|
|
165
|
+
errors += 1
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
thing_id = local_p.frontmatter.get("id", "")
|
|
169
|
+
if not thing_id:
|
|
170
|
+
click.echo(f" skip {rel}: missing id in frontmatter", err=True)
|
|
171
|
+
errors += 1
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
if _semantic_tuple(local_p) == _semantic_tuple(snap_p):
|
|
175
|
+
unchanged += 1
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
ref = local_p.frontmatter.get("ref", rel.stem)
|
|
179
|
+
|
|
180
|
+
if not force:
|
|
181
|
+
try:
|
|
182
|
+
server_thing = api_get(f"/api/things/{thing_id}/")
|
|
183
|
+
except Exception as e:
|
|
184
|
+
click.echo(f" {ref}: could not fetch server state: {e}", err=True)
|
|
185
|
+
errors += 1
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
if not isinstance(server_thing, dict):
|
|
189
|
+
click.echo(f" {ref}: unexpected API response", err=True)
|
|
190
|
+
errors += 1
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
snap_updated = snap_p.frontmatter.get("updated_at", "")
|
|
194
|
+
srv_updated = str(server_thing.get("updated_at", ""))
|
|
195
|
+
if srv_updated != snap_updated:
|
|
196
|
+
click.echo(
|
|
197
|
+
f" conflict {ref}: server was modified since last pull "
|
|
198
|
+
f"(run tsk pull, then re-apply edits). Use --force to overwrite.",
|
|
199
|
+
err=True,
|
|
200
|
+
)
|
|
201
|
+
conflicts += 1
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
payload: dict[str, Any] = {}
|
|
205
|
+
if local_p.name != snap_p.name:
|
|
206
|
+
payload["name"] = local_p.name
|
|
207
|
+
if local_p.frontmatter.get("status") != snap_p.frontmatter.get("status"):
|
|
208
|
+
payload["status"] = local_p.frontmatter.get("status", "not_started")
|
|
209
|
+
if local_p.body != snap_p.body:
|
|
210
|
+
payload["body"] = markdown_body_to_html(local_p.body)
|
|
211
|
+
|
|
212
|
+
if not payload:
|
|
213
|
+
unchanged += 1
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
updated = api_patch(f"/api/things/{thing_id}/", payload)
|
|
218
|
+
except requests.HTTPError as e:
|
|
219
|
+
click.echo(f" {ref}: PATCH failed: {e}", err=True)
|
|
220
|
+
if e.response is not None and e.response.text:
|
|
221
|
+
click.echo(f" {e.response.text[:500]}", err=True)
|
|
222
|
+
errors += 1
|
|
223
|
+
continue
|
|
224
|
+
except Exception as e:
|
|
225
|
+
click.echo(f" {ref}: PATCH failed: {e}", err=True)
|
|
226
|
+
errors += 1
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
if not isinstance(updated, dict) or not updated.get("updated_at"):
|
|
230
|
+
updated = api_get(f"/api/things/{thing_id}/")
|
|
231
|
+
|
|
232
|
+
if isinstance(updated, dict) and updated.get("updated_at"):
|
|
233
|
+
local_p.frontmatter["updated_at"] = str(updated["updated_at"])
|
|
234
|
+
new_raw = build_thing_markdown(
|
|
235
|
+
local_p.frontmatter, local_p.name, local_p.body
|
|
236
|
+
)
|
|
237
|
+
local_path.write_text(new_raw)
|
|
238
|
+
snap_path.write_text(new_raw)
|
|
239
|
+
|
|
240
|
+
pushed += 1
|
|
241
|
+
click.echo(f" pushed {ref}")
|
|
242
|
+
|
|
243
|
+
click.echo(
|
|
244
|
+
f"Done: {pushed} pushed, {unchanged} unchanged, "
|
|
245
|
+
f"{conflicts} conflict(s), {errors} error(s)"
|
|
246
|
+
)
|