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/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
+ )