workstream-cli 0.0.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.
- workstream/ARCHITECTURE.md +89 -0
- workstream/__init__.py +8 -0
- workstream/cli.py +136 -0
- workstream/commands/__init__.py +0 -0
- workstream/commands/backfill.py +139 -0
- workstream/commands/block.py +93 -0
- workstream/commands/checkin.py +51 -0
- workstream/commands/cron.py +119 -0
- workstream/commands/focus_cmd.py +273 -0
- workstream/commands/idea.py +172 -0
- workstream/commands/index.py +89 -0
- workstream/commands/init.py +567 -0
- workstream/commands/inspect_cmd.py +354 -0
- workstream/commands/list_cmd.py +99 -0
- workstream/commands/nest.py +108 -0
- workstream/commands/new.py +95 -0
- workstream/commands/next_cmd.py +333 -0
- workstream/commands/report.py +190 -0
- workstream/commands/resume.py +145 -0
- workstream/commands/review.py +227 -0
- workstream/commands/serve.py +23 -0
- workstream/commands/setup.py +178 -0
- workstream/commands/show.py +123 -0
- workstream/commands/snooze.py +117 -0
- workstream/commands/stale.py +116 -0
- workstream/commands/sweep.py +1753 -0
- workstream/commands/tree.py +105 -0
- workstream/commands/update_status.py +117 -0
- workstream/config.py +322 -0
- workstream/extensions/__init__.py +0 -0
- workstream/extensions/workstream.ts +633 -0
- workstream/focus_artifact.py +157 -0
- workstream/git.py +194 -0
- workstream/harness.py +49 -0
- workstream/llm.py +78 -0
- workstream/markdown.py +501 -0
- workstream/models.py +274 -0
- workstream/plan_index.py +88 -0
- workstream/provisioning.py +196 -0
- workstream/repo_discovery.py +158 -0
- workstream/review_artifact.py +96 -0
- workstream/scripts/migrate_statuses.py +120 -0
- workstream/skills/__init__.py +0 -0
- workstream/skills/workstream_context/SKILL.md +75 -0
- workstream/skills/workstream_context/__init__.py +0 -0
- workstream/skills/workstream_focus/SKILL.md +141 -0
- workstream/skills/workstream_init/SKILL.md +86 -0
- workstream/skills/workstream_review/SKILL.md +224 -0
- workstream/skills/workstream_sweep/SKILL.md +178 -0
- workstream/sweep_state.py +93 -0
- workstream/templates/dashboard.html +382 -0
- workstream/templates/detail.html +360 -0
- workstream/templates/plan.html +210 -0
- workstream/test/__init__.py +0 -0
- workstream/test/conftest.py +221 -0
- workstream/test/fixtures/sample_sprint_note.md +10 -0
- workstream/test/fixtures/sample_workstream.md +41 -0
- workstream/test/test_backfill.py +180 -0
- workstream/test/test_batch_writeback.py +81 -0
- workstream/test/test_commands.py +938 -0
- workstream/test/test_config.py +54 -0
- workstream/test/test_focus_artifact.py +211 -0
- workstream/test/test_git.py +88 -0
- workstream/test/test_heuristics.py +136 -0
- workstream/test/test_hierarchy.py +231 -0
- workstream/test/test_init.py +452 -0
- workstream/test/test_inspect.py +143 -0
- workstream/test/test_llm.py +78 -0
- workstream/test/test_markdown.py +626 -0
- workstream/test/test_models.py +506 -0
- workstream/test/test_next.py +206 -0
- workstream/test/test_plan_index.py +83 -0
- workstream/test/test_provisioning.py +270 -0
- workstream/test/test_repo_discovery.py +181 -0
- workstream/test/test_resume.py +71 -0
- workstream/test/test_sweep.py +1196 -0
- workstream/test/test_sweep_state.py +86 -0
- workstream/test/test_thoughts.py +516 -0
- workstream/test/test_web.py +606 -0
- workstream/thoughts.py +505 -0
- workstream/web.py +444 -0
- workstream_cli-0.0.1.dist-info/LICENSE +21 -0
- workstream_cli-0.0.1.dist-info/METADATA +93 -0
- workstream_cli-0.0.1.dist-info/RECORD +86 -0
- workstream_cli-0.0.1.dist-info/WHEEL +4 -0
- workstream_cli-0.0.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""Tests for workstream.markdown — frontmatter, sections, load/save round-trip."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from workstream.markdown import (
|
|
6
|
+
append_idea,
|
|
7
|
+
append_log_entry,
|
|
8
|
+
append_thought,
|
|
9
|
+
append_thread_entry,
|
|
10
|
+
get_section,
|
|
11
|
+
load_workstream,
|
|
12
|
+
parse_branches,
|
|
13
|
+
parse_decisions,
|
|
14
|
+
parse_frontmatter,
|
|
15
|
+
parse_ideas,
|
|
16
|
+
parse_log,
|
|
17
|
+
parse_next_actions,
|
|
18
|
+
parse_plans,
|
|
19
|
+
parse_thread,
|
|
20
|
+
parse_thoughts,
|
|
21
|
+
render_body,
|
|
22
|
+
save_workstream,
|
|
23
|
+
set_section,
|
|
24
|
+
write_frontmatter,
|
|
25
|
+
)
|
|
26
|
+
from workstream.models import (
|
|
27
|
+
BranchRef,
|
|
28
|
+
IdeaEntry,
|
|
29
|
+
LogEntry,
|
|
30
|
+
PlanRef,
|
|
31
|
+
ThoughtEntry,
|
|
32
|
+
ThreadEntry,
|
|
33
|
+
Workstream,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Sample data
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
_SAMPLE_FRONTMATTER = """\
|
|
41
|
+
---
|
|
42
|
+
id: '2026-03-28-abc1234567'
|
|
43
|
+
title: Test Stream
|
|
44
|
+
status: active
|
|
45
|
+
size: day
|
|
46
|
+
tags:
|
|
47
|
+
- infra
|
|
48
|
+
- backend
|
|
49
|
+
repos:
|
|
50
|
+
- my-repo
|
|
51
|
+
created: '2026-01-15'
|
|
52
|
+
updated: '2026-03-28'
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
_SAMPLE_BODY = """\
|
|
58
|
+
# Test Stream
|
|
59
|
+
|
|
60
|
+
#infra #backend
|
|
61
|
+
|
|
62
|
+
## Thread
|
|
63
|
+
### 2026-03-28
|
|
64
|
+
Did some work today.
|
|
65
|
+
|
|
66
|
+
Multi-line body here.
|
|
67
|
+
|
|
68
|
+
### 2026-03-27
|
|
69
|
+
Started the project.
|
|
70
|
+
|
|
71
|
+
## Decisions
|
|
72
|
+
- Use Python 3.11+
|
|
73
|
+
- Adopt face for CLI
|
|
74
|
+
|
|
75
|
+
## Plans
|
|
76
|
+
- my-repo `.plans/migrate.md` (active)
|
|
77
|
+
|
|
78
|
+
## Branches
|
|
79
|
+
- my-repo: `feature/ws-cli` (+3 ahead)
|
|
80
|
+
|
|
81
|
+
## Next
|
|
82
|
+
- Write tests
|
|
83
|
+
- Ship v1
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Frontmatter
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_parse_frontmatter_basic():
|
|
93
|
+
meta, body = parse_frontmatter(_SAMPLE_FRONTMATTER)
|
|
94
|
+
assert meta["id"] == "2026-03-28-abc1234567"
|
|
95
|
+
assert meta["title"] == "Test Stream"
|
|
96
|
+
assert meta["status"] == "active"
|
|
97
|
+
assert body.strip() == ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_parse_frontmatter_none():
|
|
101
|
+
meta, body = parse_frontmatter("No frontmatter here.\nJust text.")
|
|
102
|
+
assert meta == {}
|
|
103
|
+
assert "No frontmatter here." in body
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_parse_frontmatter_no_close():
|
|
107
|
+
text = "---\nid: oops\nNo closing delimiter"
|
|
108
|
+
meta, body = parse_frontmatter(text)
|
|
109
|
+
assert meta == {}
|
|
110
|
+
assert body == text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_write_frontmatter_roundtrip():
|
|
114
|
+
meta, _ = parse_frontmatter(_SAMPLE_FRONTMATTER)
|
|
115
|
+
rebuilt = write_frontmatter(meta, "")
|
|
116
|
+
meta2, _ = parse_frontmatter(rebuilt)
|
|
117
|
+
for key in ("id", "title", "status", "size"):
|
|
118
|
+
assert str(meta[key]) == str(meta2[key])
|
|
119
|
+
assert meta2["tags"] == meta["tags"]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Thread
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_parse_thread_entries():
|
|
128
|
+
entries = parse_thread(_SAMPLE_BODY)
|
|
129
|
+
assert len(entries) == 2
|
|
130
|
+
assert entries[0].date == "2026-03-28"
|
|
131
|
+
assert "Did some work today." in entries[0].body
|
|
132
|
+
# Multi-line body preserved
|
|
133
|
+
assert "Multi-line body here." in entries[0].body
|
|
134
|
+
assert entries[1].date == "2026-03-27"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_parse_thread_empty():
|
|
138
|
+
body = "## Decisions\n- Something\n"
|
|
139
|
+
assert parse_thread(body) == []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Bullets
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_parse_decisions():
|
|
148
|
+
items = parse_decisions(_SAMPLE_BODY)
|
|
149
|
+
assert items == ["Use Python 3.11+", "Adopt face for CLI"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_parse_plans():
|
|
153
|
+
plans = parse_plans(_SAMPLE_BODY)
|
|
154
|
+
assert len(plans) == 1
|
|
155
|
+
assert plans[0] == PlanRef(repo="my-repo", path="migrate.md", status="active", title='', date='')
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_parse_branches():
|
|
159
|
+
branches = parse_branches(_SAMPLE_BODY)
|
|
160
|
+
assert len(branches) == 1
|
|
161
|
+
assert branches[0] == BranchRef(repo="my-repo", branch="feature/ws-cli", ahead=3)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_parse_next_actions():
|
|
165
|
+
items = parse_next_actions(_SAMPLE_BODY)
|
|
166
|
+
assert items == ["Write tests", "Ship v1"]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Sections
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_get_section_present():
|
|
175
|
+
section = get_section(_SAMPLE_BODY, "Decisions")
|
|
176
|
+
assert section is not None
|
|
177
|
+
assert "Use Python 3.11+" in section
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_get_section_absent():
|
|
181
|
+
assert get_section(_SAMPLE_BODY, "Nonexistent") is None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_set_section_replace():
|
|
185
|
+
body = "## Alpha\nold content\n\n## Beta\nkeep\n"
|
|
186
|
+
updated = set_section(body, "Alpha", "new content")
|
|
187
|
+
assert "new content" in updated
|
|
188
|
+
assert "old content" not in updated
|
|
189
|
+
# Beta untouched
|
|
190
|
+
assert "keep" in updated
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_set_section_append():
|
|
194
|
+
body = "## Alpha\ncontent\n"
|
|
195
|
+
updated = set_section(body, "Gamma", "gamma stuff")
|
|
196
|
+
assert "## Gamma" in updated
|
|
197
|
+
assert "gamma stuff" in updated
|
|
198
|
+
# Original section preserved
|
|
199
|
+
assert "## Alpha" in updated
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Thread mutation
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_append_thread_entry():
|
|
208
|
+
body = "## Thread\n### 2026-03-27\nOld entry.\n"
|
|
209
|
+
updated = append_thread_entry(body, "2026-03-28", "New entry.")
|
|
210
|
+
# New entry at top
|
|
211
|
+
thread_pos = updated.index("## Thread\n")
|
|
212
|
+
new_pos = updated.index("### 2026-03-28")
|
|
213
|
+
old_pos = updated.index("### 2026-03-27")
|
|
214
|
+
assert new_pos < old_pos
|
|
215
|
+
assert "New entry." in updated
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Full file round-trip
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_load_workstream(tmp_path: Path):
|
|
224
|
+
content = _SAMPLE_FRONTMATTER + _SAMPLE_BODY
|
|
225
|
+
fp = tmp_path / "test.md"
|
|
226
|
+
fp.write_text(content, encoding="utf-8")
|
|
227
|
+
|
|
228
|
+
ws = load_workstream(fp)
|
|
229
|
+
assert ws.id == "2026-03-28-abc1234567"
|
|
230
|
+
assert ws.title == "Test Stream"
|
|
231
|
+
assert ws.status == "active"
|
|
232
|
+
# PyYAML may parse '2026-01-15' as datetime.date; load_workstream casts to str
|
|
233
|
+
assert ws.created == "2026-01-15"
|
|
234
|
+
assert ws.updated == "2026-03-28"
|
|
235
|
+
assert ws.tags == ["infra", "backend"]
|
|
236
|
+
assert ws.repos == ["my-repo"]
|
|
237
|
+
assert len(ws.thread) == 2
|
|
238
|
+
assert len(ws.decisions) == 2
|
|
239
|
+
assert len(ws.plans) == 1
|
|
240
|
+
assert ws.plans[0].repo == "my-repo"
|
|
241
|
+
assert len(ws.branches) == 1
|
|
242
|
+
assert len(ws.next_actions) == 2
|
|
243
|
+
assert ws.source_path == fp
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_save_and_reload(tmp_path: Path):
|
|
247
|
+
ws = Workstream(
|
|
248
|
+
id="2026-03-28-0000000000",
|
|
249
|
+
title="Round Trip",
|
|
250
|
+
status="active",
|
|
251
|
+
size="week",
|
|
252
|
+
tags=["cli"],
|
|
253
|
+
repos=["core"],
|
|
254
|
+
created="2026-01-01",
|
|
255
|
+
updated="2026-03-28",
|
|
256
|
+
thread=[ThreadEntry(date="2026-03-28", body="Entry body.")],
|
|
257
|
+
decisions=["Decision A"],
|
|
258
|
+
plans=[PlanRef(repo="core", path="plan.md", status="draft", title='', date='')],
|
|
259
|
+
branches=[BranchRef(repo="core", branch="feat/x", ahead=5)],
|
|
260
|
+
next_actions=["Do something"],
|
|
261
|
+
)
|
|
262
|
+
fp = tmp_path / ws.filename
|
|
263
|
+
save_workstream(ws, fp)
|
|
264
|
+
|
|
265
|
+
loaded = load_workstream(fp)
|
|
266
|
+
assert loaded.id == ws.id
|
|
267
|
+
assert loaded.title == ws.title
|
|
268
|
+
assert loaded.status == ws.status
|
|
269
|
+
assert loaded.size == ws.size
|
|
270
|
+
assert loaded.tags == ws.tags
|
|
271
|
+
assert loaded.repos == ws.repos
|
|
272
|
+
assert loaded.created == ws.created
|
|
273
|
+
assert loaded.updated == ws.updated
|
|
274
|
+
assert len(loaded.thread) == 1
|
|
275
|
+
assert loaded.thread[0].date == "2026-03-28"
|
|
276
|
+
assert "Entry body." in loaded.thread[0].body
|
|
277
|
+
assert loaded.decisions == ["Decision A"]
|
|
278
|
+
assert len(loaded.plans) == 1
|
|
279
|
+
assert loaded.plans[0].repo == "core"
|
|
280
|
+
assert len(loaded.branches) == 1
|
|
281
|
+
assert loaded.branches[0].ahead == 5
|
|
282
|
+
assert loaded.next_actions == ["Do something"]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_render_body():
|
|
286
|
+
ws = Workstream(
|
|
287
|
+
id="x",
|
|
288
|
+
title="Render Test",
|
|
289
|
+
tags=["t1"],
|
|
290
|
+
thread=[ThreadEntry(date="2026-03-28", body="Note.")],
|
|
291
|
+
next_actions=["Act"],
|
|
292
|
+
)
|
|
293
|
+
body = render_body(ws)
|
|
294
|
+
assert body.startswith("# Render Test\n")
|
|
295
|
+
assert "#t1" in body
|
|
296
|
+
assert "## Thread" in body
|
|
297
|
+
assert "### 2026-03-28" in body
|
|
298
|
+
assert "Note." in body
|
|
299
|
+
assert "## Next" in body
|
|
300
|
+
assert "- Act" in body
|
|
301
|
+
# Empty optional sections omitted
|
|
302
|
+
assert "## Decisions" not in body
|
|
303
|
+
assert "## Plans" not in body
|
|
304
|
+
assert "## Branches" not in body
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# Thoughts section
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_parse_thoughts_basic():
|
|
314
|
+
body = '## Thoughts\n- [2026-03-28] Should add confetti\n- [2026-03-27] Rethink API versioning\n'
|
|
315
|
+
thoughts = parse_thoughts(body)
|
|
316
|
+
assert len(thoughts) == 2
|
|
317
|
+
assert thoughts[0].date == '2026-03-28'
|
|
318
|
+
assert thoughts[0].text == 'Should add confetti'
|
|
319
|
+
assert thoughts[0].snooze_until == ''
|
|
320
|
+
assert thoughts[0].snooze_count == 0
|
|
321
|
+
assert thoughts[1].date == '2026-03-27'
|
|
322
|
+
assert thoughts[1].text == 'Rethink API versioning'
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def test_parse_thoughts_with_snooze():
|
|
326
|
+
body = '## Thoughts\n- [2026-03-28] Some idea (snooze: 2026-04-04, count: 2)\n'
|
|
327
|
+
thoughts = parse_thoughts(body)
|
|
328
|
+
assert len(thoughts) == 1
|
|
329
|
+
assert thoughts[0].text == 'Some idea'
|
|
330
|
+
assert thoughts[0].snooze_until == '2026-04-04'
|
|
331
|
+
assert thoughts[0].snooze_count == 2
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_parse_thoughts_empty():
|
|
335
|
+
body = '## Thread\n### 2026-03-28\nSome work.\n'
|
|
336
|
+
assert parse_thoughts(body) == []
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_append_thought_creates_section():
|
|
340
|
+
body = '## Thread\nSome content\n'
|
|
341
|
+
t = ThoughtEntry(date='2026-03-28', text='New idea')
|
|
342
|
+
updated = append_thought(body, t)
|
|
343
|
+
assert '## Thoughts' in updated
|
|
344
|
+
assert '- [2026-03-28] New idea' in updated
|
|
345
|
+
# Original content preserved
|
|
346
|
+
assert '## Thread' in updated
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_append_thought_to_existing():
|
|
350
|
+
body = '## Thoughts\n- [2026-03-27] Old idea\n'
|
|
351
|
+
t = ThoughtEntry(date='2026-03-28', text='New idea')
|
|
352
|
+
updated = append_thought(body, t)
|
|
353
|
+
assert '- [2026-03-27] Old idea' in updated
|
|
354
|
+
assert '- [2026-03-28] New idea' in updated
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_render_body_with_thoughts():
|
|
358
|
+
ws = Workstream(
|
|
359
|
+
id='x',
|
|
360
|
+
title='Test',
|
|
361
|
+
thoughts=[ThoughtEntry(date='2026-03-28', text='Cool idea')],
|
|
362
|
+
next_actions=['Do something'],
|
|
363
|
+
)
|
|
364
|
+
body = render_body(ws)
|
|
365
|
+
assert '## Thoughts' in body
|
|
366
|
+
assert '- [2026-03-28] Cool idea' in body
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_render_body_omits_empty_thoughts():
|
|
370
|
+
ws = Workstream(id='x', title='Test', next_actions=['Do something'])
|
|
371
|
+
body = render_body(ws)
|
|
372
|
+
assert '## Thoughts' not in body
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
# Hierarchy fields round-trip
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_load_workstream_with_parent(tmp_path: Path):
|
|
381
|
+
content = '''---
|
|
382
|
+
id: '2026-03-28-abc1234567'
|
|
383
|
+
title: Child Stream
|
|
384
|
+
status: active
|
|
385
|
+
parent: '2026-01-01-parent123'
|
|
386
|
+
snooze_until: '2026-04-15'
|
|
387
|
+
snooze_count: 2
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
# Child Stream
|
|
391
|
+
|
|
392
|
+
## Thread
|
|
393
|
+
### 2026-03-28
|
|
394
|
+
Some work.
|
|
395
|
+
|
|
396
|
+
## Next
|
|
397
|
+
- Do something
|
|
398
|
+
'''
|
|
399
|
+
fp = tmp_path / 'test.md'
|
|
400
|
+
fp.write_text(content, encoding='utf-8')
|
|
401
|
+
ws = load_workstream(fp)
|
|
402
|
+
assert ws.parent == '2026-01-01-parent123'
|
|
403
|
+
assert ws.snooze_until == '2026-04-15'
|
|
404
|
+
assert ws.snooze_count == 2
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def test_save_and_reload_with_parent_and_thoughts(tmp_path: Path):
|
|
408
|
+
ws = Workstream(
|
|
409
|
+
id='2026-03-28-0000000000',
|
|
410
|
+
title='Round Trip',
|
|
411
|
+
status='active',
|
|
412
|
+
parent='2026-01-01-parent123',
|
|
413
|
+
snooze_until='2026-05-01',
|
|
414
|
+
snooze_count=1,
|
|
415
|
+
thread=[ThreadEntry(date='2026-03-28', body='Entry body.')],
|
|
416
|
+
thoughts=[
|
|
417
|
+
ThoughtEntry(date='2026-03-28', text='A thought'),
|
|
418
|
+
ThoughtEntry(date='2026-03-27', text='Snoozed thought', snooze_until='2026-04-10', snooze_count=3),
|
|
419
|
+
],
|
|
420
|
+
next_actions=['Do something'],
|
|
421
|
+
)
|
|
422
|
+
fp = tmp_path / ws.filename
|
|
423
|
+
save_workstream(ws, fp)
|
|
424
|
+
|
|
425
|
+
loaded = load_workstream(fp)
|
|
426
|
+
assert loaded.parent == '2026-01-01-parent123'
|
|
427
|
+
assert loaded.snooze_until == '2026-05-01'
|
|
428
|
+
assert loaded.snooze_count == 1
|
|
429
|
+
assert len(loaded.thoughts) == 2
|
|
430
|
+
assert loaded.thoughts[0].date == '2026-03-28'
|
|
431
|
+
assert loaded.thoughts[0].text == 'A thought'
|
|
432
|
+
assert loaded.thoughts[0].snooze_until == ''
|
|
433
|
+
assert loaded.thoughts[1].text == 'Snoozed thought'
|
|
434
|
+
assert loaded.thoughts[1].snooze_until == '2026-04-10'
|
|
435
|
+
assert loaded.thoughts[1].snooze_count == 3
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---------------------------------------------------------------------------
|
|
439
|
+
# Ideas section
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def test_parse_ideas_basic():
|
|
444
|
+
body = '## Ideas\n- [2026-03-28] Build a rate alert system\n- [2026-03-27] Add a leaderboard\n'
|
|
445
|
+
ideas = parse_ideas(body)
|
|
446
|
+
assert len(ideas) == 2
|
|
447
|
+
assert ideas[0].date == '2026-03-28'
|
|
448
|
+
assert ideas[0].text == 'Build a rate alert system'
|
|
449
|
+
assert ideas[0].snooze_until == ''
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_parse_ideas_with_snooze():
|
|
453
|
+
body = '## Ideas\n- [2026-03-28] Some idea (snooze: 2026-04-04, count: 1)\n'
|
|
454
|
+
ideas = parse_ideas(body)
|
|
455
|
+
assert len(ideas) == 1
|
|
456
|
+
assert ideas[0].snooze_until == '2026-04-04'
|
|
457
|
+
assert ideas[0].snooze_count == 1
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_parse_ideas_empty():
|
|
461
|
+
body = '## Thread\n### 2026-03-28\nSome work.\n'
|
|
462
|
+
assert parse_ideas(body) == []
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def test_append_idea_creates_section():
|
|
466
|
+
body = '## Thread\nSome content\n'
|
|
467
|
+
idea = IdeaEntry(date='2026-03-28', text='New idea')
|
|
468
|
+
updated = append_idea(body, idea)
|
|
469
|
+
assert '## Ideas' in updated
|
|
470
|
+
assert '- [2026-03-28] New idea' in updated
|
|
471
|
+
assert '## Thread' in updated
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def test_append_idea_to_existing():
|
|
475
|
+
body = '## Ideas\n- [2026-03-27] Old idea\n'
|
|
476
|
+
idea = IdeaEntry(date='2026-03-28', text='New idea')
|
|
477
|
+
updated = append_idea(body, idea)
|
|
478
|
+
assert '- [2026-03-27] Old idea' in updated
|
|
479
|
+
assert '- [2026-03-28] New idea' in updated
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ---------------------------------------------------------------------------
|
|
483
|
+
# Log section
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def test_parse_log_basic():
|
|
488
|
+
body = '## Log\n- [2026-03-28] created (active, day)\n- [2026-03-29] snoozed until 2026-04-07: Waiting on review\n'
|
|
489
|
+
log = parse_log(body)
|
|
490
|
+
assert len(log) == 2
|
|
491
|
+
assert log[0].date == '2026-03-28'
|
|
492
|
+
assert log[0].event == 'created'
|
|
493
|
+
assert log[0].detail == '(active, day)'
|
|
494
|
+
assert log[1].event == 'snoozed'
|
|
495
|
+
assert 'Waiting on review' in log[1].detail
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def test_parse_log_empty():
|
|
499
|
+
body = '## Thread\nSome work.\n'
|
|
500
|
+
assert parse_log(body) == []
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def test_append_log_entry_creates_section():
|
|
504
|
+
body = '## Thread\nSome content\n'
|
|
505
|
+
entry = LogEntry(date='2026-03-28', event='created', detail='(active, day)')
|
|
506
|
+
updated = append_log_entry(body, entry)
|
|
507
|
+
assert '## Log' in updated
|
|
508
|
+
assert '- [2026-03-28] created (active, day)' in updated
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def test_append_log_entry_to_existing():
|
|
512
|
+
body = '## Log\n- [2026-03-28] created (active, day)\n'
|
|
513
|
+
entry = LogEntry(date='2026-03-29', event='snoozed', detail='until 2026-04-07: Blocked')
|
|
514
|
+
updated = append_log_entry(body, entry)
|
|
515
|
+
assert '- [2026-03-28] created' in updated
|
|
516
|
+
assert '- [2026-03-29] snoozed until 2026-04-07: Blocked' in updated
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ---------------------------------------------------------------------------
|
|
520
|
+
# Render body with ideas and log
|
|
521
|
+
# ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def test_render_body_with_ideas_and_log():
|
|
525
|
+
ws = Workstream(
|
|
526
|
+
id='x',
|
|
527
|
+
title='Test',
|
|
528
|
+
ideas=[IdeaEntry(date='2026-03-28', text='Cool idea')],
|
|
529
|
+
log=[LogEntry(date='2026-03-28', event='created', detail='(active, day)')],
|
|
530
|
+
next_actions=['Do something'],
|
|
531
|
+
)
|
|
532
|
+
body = render_body(ws)
|
|
533
|
+
assert '## Ideas' in body
|
|
534
|
+
assert '- [2026-03-28] Cool idea' in body
|
|
535
|
+
assert '## Log' in body
|
|
536
|
+
assert '- [2026-03-28] created (active, day)' in body
|
|
537
|
+
# Log should appear after Next
|
|
538
|
+
next_pos = body.index('## Next')
|
|
539
|
+
log_pos = body.index('## Log')
|
|
540
|
+
assert log_pos > next_pos
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
# Plans 30-day terminal window
|
|
545
|
+
# ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def test_render_body_plans_30day_window():
|
|
549
|
+
"""All plans are rendered regardless of age — no truncation."""
|
|
550
|
+
from datetime import date, timedelta
|
|
551
|
+
|
|
552
|
+
today = date.today()
|
|
553
|
+
recent = (today - timedelta(days=10)).isoformat()
|
|
554
|
+
old = (today - timedelta(days=60)).isoformat()
|
|
555
|
+
ancient = (today - timedelta(days=120)).isoformat()
|
|
556
|
+
|
|
557
|
+
ws = Workstream(
|
|
558
|
+
id='x', title='Test',
|
|
559
|
+
plans=[
|
|
560
|
+
PlanRef(repo='r', path='active.md', status='active', title='Active', date=today.isoformat()),
|
|
561
|
+
PlanRef(repo='r', path='recent-impl.md', status='implemented', title='Recent', date=recent),
|
|
562
|
+
PlanRef(repo='r', path='old-impl.md', status='implemented', title='Old Impl', date=old),
|
|
563
|
+
PlanRef(repo='r', path='old-obs.md', status='obsolete', title='Old Obs', date=ancient),
|
|
564
|
+
PlanRef(repo='r', path='tabled.md', status='tabled', title='Tabled', date=old),
|
|
565
|
+
],
|
|
566
|
+
)
|
|
567
|
+
body = render_body(ws)
|
|
568
|
+
|
|
569
|
+
# All plans visible — no truncation
|
|
570
|
+
assert 'active.md' in body
|
|
571
|
+
assert 'tabled.md' in body
|
|
572
|
+
assert 'recent-impl.md' in body
|
|
573
|
+
assert 'old-impl.md' in body
|
|
574
|
+
assert 'old-obs.md' in body
|
|
575
|
+
# No summary comment
|
|
576
|
+
assert 'older plans' not in body
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def test_render_body_plans_no_old_terminal():
|
|
580
|
+
"""No summary comment when all terminal plans are recent."""
|
|
581
|
+
from datetime import date, timedelta
|
|
582
|
+
|
|
583
|
+
recent = (date.today() - timedelta(days=5)).isoformat()
|
|
584
|
+
ws = Workstream(
|
|
585
|
+
id='x', title='Test',
|
|
586
|
+
plans=[
|
|
587
|
+
PlanRef(repo='r', path='done.md', status='implemented', title='Done', date=recent),
|
|
588
|
+
],
|
|
589
|
+
)
|
|
590
|
+
body = render_body(ws)
|
|
591
|
+
assert 'done.md' in body
|
|
592
|
+
assert 'older plans' not in body
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def test_plan_roundtrip_plan_type_guidance():
|
|
596
|
+
"""render_body -> parse_plans round-trips plan_type and guidance fields."""
|
|
597
|
+
ws = Workstream(
|
|
598
|
+
id='x', title='Test',
|
|
599
|
+
plans=[
|
|
600
|
+
PlanRef(repo='r', path='proj.md', status='active', title='Big Project',
|
|
601
|
+
date='2026-03-01', signal='', plan_type='project', guidance='new-peer'),
|
|
602
|
+
PlanRef(repo='r', path='tac.md', status='draft', title='Quick Fix',
|
|
603
|
+
date='2026-03-02', plan_type='tactical', guidance='keep-current'),
|
|
604
|
+
PlanRef(repo='r', path='plain.md', status='active', title='Plain',
|
|
605
|
+
date='2026-03-03'),
|
|
606
|
+
],
|
|
607
|
+
)
|
|
608
|
+
body = render_body(ws)
|
|
609
|
+
|
|
610
|
+
# Verify serialized format
|
|
611
|
+
assert 'type:project' in body
|
|
612
|
+
assert 'guidance:new-peer' in body
|
|
613
|
+
assert 'type:tactical' in body
|
|
614
|
+
assert 'guidance:keep-current' in body
|
|
615
|
+
|
|
616
|
+
# Round-trip
|
|
617
|
+
plans = parse_plans(body)
|
|
618
|
+
assert len(plans) == 3
|
|
619
|
+
|
|
620
|
+
by_path = {p.path: p for p in plans}
|
|
621
|
+
assert by_path['proj.md'].plan_type == 'project'
|
|
622
|
+
assert by_path['proj.md'].guidance == 'new-peer'
|
|
623
|
+
assert by_path['tac.md'].plan_type == 'tactical'
|
|
624
|
+
assert by_path['tac.md'].guidance == 'keep-current'
|
|
625
|
+
assert by_path['plain.md'].plan_type == ''
|
|
626
|
+
assert by_path['plain.md'].guidance == ''
|