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,221 @@
|
|
|
1
|
+
"""Shared pytest fixtures for workstream tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from textwrap import dedent
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from workstream.config import Config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Raw markdown text fixture
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
SAMPLE_WORKSTREAM_TEXT = dedent("""\
|
|
18
|
+
---
|
|
19
|
+
id: 2026-03-15-a1b2c3d4e5
|
|
20
|
+
title: API Redesign
|
|
21
|
+
status: active
|
|
22
|
+
size: week
|
|
23
|
+
tags:
|
|
24
|
+
- code
|
|
25
|
+
- backend
|
|
26
|
+
repos:
|
|
27
|
+
- myapp
|
|
28
|
+
created: '2026-03-15'
|
|
29
|
+
updated: '2026-03-25'
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
# API Redesign
|
|
33
|
+
#code #backend
|
|
34
|
+
|
|
35
|
+
## Thread
|
|
36
|
+
### 2026-03-25
|
|
37
|
+
Refactored authentication middleware to use the new token format.
|
|
38
|
+
Endpoint tests all passing now.
|
|
39
|
+
|
|
40
|
+
### 2026-03-20
|
|
41
|
+
Sketched out the new resource hierarchy. Going with /v2/ prefix
|
|
42
|
+
to avoid breaking existing clients.
|
|
43
|
+
|
|
44
|
+
## Decisions
|
|
45
|
+
- Use /v2/ prefix for all new endpoints
|
|
46
|
+
- Adopt JSON:API response envelope
|
|
47
|
+
|
|
48
|
+
## Plans
|
|
49
|
+
<!-- Updated by ws sweep -->
|
|
50
|
+
- myapp `.plans/api-v2-migration.md` (active)
|
|
51
|
+
|
|
52
|
+
## Branches
|
|
53
|
+
<!-- Updated by ws sweep -->
|
|
54
|
+
- myapp: `feature/api-v2` (+12 ahead)
|
|
55
|
+
|
|
56
|
+
## Next
|
|
57
|
+
- Write migration guide for consumers
|
|
58
|
+
- Add rate-limiting to /v2/ endpoints
|
|
59
|
+
""")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture()
|
|
63
|
+
def sample_workstream_text() -> str:
|
|
64
|
+
"""Complete workstream markdown with frontmatter and all sections."""
|
|
65
|
+
return SAMPLE_WORKSTREAM_TEXT
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Directory of workstream files
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
_WORKSTREAM_SPECS: list[dict] = [
|
|
73
|
+
{
|
|
74
|
+
"filename": "2026-03-15-a1b2c3d4e5-api-redesign.md",
|
|
75
|
+
"id": "2026-03-15-a1b2c3d4e5",
|
|
76
|
+
"title": "API Redesign",
|
|
77
|
+
"status": "active",
|
|
78
|
+
"size": "week",
|
|
79
|
+
"tags": ["code"],
|
|
80
|
+
"created": "2026-03-15",
|
|
81
|
+
"updated": "2026-03-25",
|
|
82
|
+
"last_activity": "2026-03-25",
|
|
83
|
+
"thread_date": "2026-03-25",
|
|
84
|
+
"thread_body": "Refactored auth middleware. Tests passing.",
|
|
85
|
+
"next": ["Write migration guide", "Add rate-limiting"],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"filename": "2026-03-10-b2c3d4e5f6-docs-overhaul.md",
|
|
89
|
+
"id": "2026-03-10-b2c3d4e5f6",
|
|
90
|
+
"title": "Docs Overhaul",
|
|
91
|
+
"status": "active",
|
|
92
|
+
"size": "day",
|
|
93
|
+
"tags": ["code", "docs"],
|
|
94
|
+
"created": "2026-03-10",
|
|
95
|
+
"updated": "2026-03-22",
|
|
96
|
+
"last_activity": "2026-03-22",
|
|
97
|
+
"thread_date": "2026-03-22",
|
|
98
|
+
"thread_body": "Finished API reference section. Need to add examples.",
|
|
99
|
+
"next": ["Add code examples to each endpoint"],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"filename": "2026-03-01-c3d4e5f6a7-conference-talk.md",
|
|
103
|
+
"id": "2026-03-01-c3d4e5f6a7",
|
|
104
|
+
"title": "Conference Talk",
|
|
105
|
+
"status": "snoozed",
|
|
106
|
+
"size": "day",
|
|
107
|
+
"tags": ["community"],
|
|
108
|
+
"snooze_reason": "Waiting on CFP results",
|
|
109
|
+
"created": "2026-03-01",
|
|
110
|
+
"updated": "2026-03-12",
|
|
111
|
+
"last_activity": "2026-03-12",
|
|
112
|
+
"thread_date": "2026-03-12",
|
|
113
|
+
"thread_body": "Submitted CFP abstract. Fingers crossed.",
|
|
114
|
+
"next": ["Prepare slide outline once accepted"],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"filename": "2026-03-20-d4e5f6a7b8-accessibility-audit.md",
|
|
118
|
+
"id": "2026-03-20-d4e5f6a7b8",
|
|
119
|
+
"title": "Accessibility Audit",
|
|
120
|
+
"status": "active",
|
|
121
|
+
"size": "hour",
|
|
122
|
+
"tags": ["code"],
|
|
123
|
+
"created": "2026-03-20",
|
|
124
|
+
"updated": "2026-03-20",
|
|
125
|
+
"last_activity": "2026-03-20",
|
|
126
|
+
"thread_date": "2026-03-20",
|
|
127
|
+
"thread_body": "Initial idea: run axe-core across all pages.",
|
|
128
|
+
"next": ["Pick an audit tool"],
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"filename": "2026-03-05-e5f6a7b8c9-sponsorship-outreach.md",
|
|
132
|
+
"id": "2026-03-05-e5f6a7b8c9",
|
|
133
|
+
"title": "Sponsorship Outreach",
|
|
134
|
+
"status": "active",
|
|
135
|
+
"size": "week",
|
|
136
|
+
"tags": ["business"],
|
|
137
|
+
"created": "2026-03-05",
|
|
138
|
+
"updated": "2026-03-18",
|
|
139
|
+
"last_activity": "2026-03-18",
|
|
140
|
+
"thread_date": "2026-03-18",
|
|
141
|
+
"thread_body": "Sent first batch of emails. Three positive replies so far.",
|
|
142
|
+
"next": ["Follow up with remaining prospects", "Draft sponsorship tiers"],
|
|
143
|
+
},
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _render_workstream_file(spec: dict) -> str:
|
|
148
|
+
"""Build a minimal but valid workstream markdown file from a spec dict."""
|
|
149
|
+
lines = [
|
|
150
|
+
"---",
|
|
151
|
+
f"id: {spec['id']}",
|
|
152
|
+
f"title: {spec['title']}",
|
|
153
|
+
f"status: {spec['status']}",
|
|
154
|
+
f"size: {spec['size']}",
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
tags = spec.get("tags", [])
|
|
158
|
+
if tags:
|
|
159
|
+
lines.append("tags:")
|
|
160
|
+
for t in tags:
|
|
161
|
+
lines.append(f" - {t}")
|
|
162
|
+
|
|
163
|
+
if spec.get("snooze_reason"):
|
|
164
|
+
lines.append(f"snooze_reason: '{spec['snooze_reason']}'")
|
|
165
|
+
|
|
166
|
+
lines.append(f"created: '{spec['created']}'")
|
|
167
|
+
lines.append(f"updated: '{spec['updated']}'")
|
|
168
|
+
if spec.get('last_activity'):
|
|
169
|
+
lines.append(f"last_activity: '{spec['last_activity']}'")
|
|
170
|
+
lines.append("---")
|
|
171
|
+
lines.append("")
|
|
172
|
+
lines.append(f"# {spec['title']}")
|
|
173
|
+
lines.append("")
|
|
174
|
+
lines.append("## Thread")
|
|
175
|
+
lines.append(f"### {spec['thread_date']}")
|
|
176
|
+
lines.append(spec["thread_body"])
|
|
177
|
+
lines.append("")
|
|
178
|
+
lines.append("## Next")
|
|
179
|
+
for action in spec.get("next", []):
|
|
180
|
+
lines.append(f"- {action}")
|
|
181
|
+
lines.append("")
|
|
182
|
+
|
|
183
|
+
return "\n".join(lines)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@pytest.fixture()
|
|
187
|
+
def workstreams_dir(tmp_path: Path) -> Path:
|
|
188
|
+
"""Temp directory populated with 5 sample workstream files."""
|
|
189
|
+
ws_dir = tmp_path / "workstreams"
|
|
190
|
+
ws_dir.mkdir()
|
|
191
|
+
|
|
192
|
+
for spec in _WORKSTREAM_SPECS:
|
|
193
|
+
(ws_dir / spec["filename"]).write_text(
|
|
194
|
+
_render_workstream_file(spec), encoding="utf-8"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return ws_dir
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# Config fixture
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
@pytest.fixture()
|
|
205
|
+
def test_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Config:
|
|
206
|
+
"""Config with workstreams_dir in a temp directory.
|
|
207
|
+
|
|
208
|
+
Patches global config path and WS_DIR env to prevent leaking to the real
|
|
209
|
+
filesystem. Most command tests also monkeypatch ``load_config`` directly.
|
|
210
|
+
"""
|
|
211
|
+
ws_dir = tmp_path / "workstreams"
|
|
212
|
+
ws_dir.mkdir()
|
|
213
|
+
|
|
214
|
+
# Prevent global config reads/writes from hitting the real home dir
|
|
215
|
+
config_file = tmp_path / "global_config.yaml"
|
|
216
|
+
monkeypatch.setattr("workstream.config.CONFIG_PATH", config_file)
|
|
217
|
+
monkeypatch.setattr("workstream.config.CONFIG_DIR", tmp_path)
|
|
218
|
+
# Prevent env var and cwd walk-up from finding a real project
|
|
219
|
+
monkeypatch.delenv("WS_DIR", raising=False)
|
|
220
|
+
|
|
221
|
+
return Config(workstreams_dir=str(ws_dir))
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
## 2026-03-26
|
|
2
|
+
- TODO
|
|
3
|
+
- Fix onboarding bug
|
|
4
|
+
- Review PR #42
|
|
5
|
+
- Thoughts
|
|
6
|
+
- Should we add confetti to the welcome screen?
|
|
7
|
+
- Time to rethink the API versioning strategy
|
|
8
|
+
- That agent knowledge sharing idea keeps coming up
|
|
9
|
+
- Done
|
|
10
|
+
- Deployed landing page
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: 2026-03-15-a1b2c3d4e5
|
|
3
|
+
title: API Redesign
|
|
4
|
+
status: active
|
|
5
|
+
size: week
|
|
6
|
+
tags:
|
|
7
|
+
- code
|
|
8
|
+
- backend
|
|
9
|
+
repos:
|
|
10
|
+
- myapp
|
|
11
|
+
created: '2026-03-15'
|
|
12
|
+
updated: '2026-03-25'
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# API Redesign
|
|
16
|
+
#code #backend
|
|
17
|
+
|
|
18
|
+
## Thread
|
|
19
|
+
### 2026-03-25
|
|
20
|
+
Refactored authentication middleware to use the new token format.
|
|
21
|
+
Endpoint tests all passing now.
|
|
22
|
+
|
|
23
|
+
### 2026-03-20
|
|
24
|
+
Sketched out the new resource hierarchy. Going with /v2/ prefix
|
|
25
|
+
to avoid breaking existing clients.
|
|
26
|
+
|
|
27
|
+
## Decisions
|
|
28
|
+
- Use /v2/ prefix for all new endpoints
|
|
29
|
+
- Adopt JSON:API response envelope
|
|
30
|
+
|
|
31
|
+
## Plans
|
|
32
|
+
<!-- Updated by ws sweep -->
|
|
33
|
+
- myapp `.plans/api-v2-migration.md` (active)
|
|
34
|
+
|
|
35
|
+
## Branches
|
|
36
|
+
<!-- Updated by ws sweep -->
|
|
37
|
+
- myapp: `feature/api-v2` (+12 ahead)
|
|
38
|
+
|
|
39
|
+
## Next
|
|
40
|
+
- Write migration guide for consumers
|
|
41
|
+
- Add rate-limiting to /v2/ endpoints
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Tests for workstream.commands.backfill — plan ID assignment and workstream inference."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from textwrap import dedent
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from workstream.commands.backfill import _slug_overlap, _infer_workstream
|
|
11
|
+
from workstream.config import Config, RepoConfig
|
|
12
|
+
from workstream.markdown import parse_frontmatter
|
|
13
|
+
from workstream.models import Workstream
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── Slug overlap ─────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
def test_slug_overlap_match():
|
|
19
|
+
"""Overlapping tokens return True."""
|
|
20
|
+
assert _slug_overlap('api-redesign-migration', 'api-redesign')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_slug_overlap_no_match():
|
|
24
|
+
"""No overlapping tokens return False."""
|
|
25
|
+
assert not _slug_overlap('perf-improvements', 'api-redesign')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_slug_overlap_short_tokens_ignored():
|
|
29
|
+
"""Single-char tokens are ignored."""
|
|
30
|
+
assert not _slug_overlap('a-b-c', 'a-b-d')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Workstream inference ─────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
def test_infer_single_match():
|
|
36
|
+
"""Exactly one matching workstream returns its ID."""
|
|
37
|
+
ws = Workstream(id='ws-123', title='API Redesign', status='active', repos=['myapp'])
|
|
38
|
+
assert _infer_workstream('myapp', 'api-redesign-migration', [ws]) == 'ws-123'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_infer_no_repo_match():
|
|
42
|
+
"""No matching repo returns None."""
|
|
43
|
+
ws = Workstream(id='ws-123', title='API Redesign', status='active', repos=['otherapp'])
|
|
44
|
+
assert _infer_workstream('myapp', 'api-redesign', [ws]) is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_infer_no_slug_overlap():
|
|
48
|
+
"""Matching repo but no slug overlap returns None."""
|
|
49
|
+
ws = Workstream(id='ws-123', title='Fundraising', status='active', repos=['myapp'])
|
|
50
|
+
assert _infer_workstream('myapp', 'api-redesign', [ws]) is None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_infer_multiple_matches_returns_none():
|
|
54
|
+
"""Multiple matching workstreams returns None (ambiguous)."""
|
|
55
|
+
ws1 = Workstream(id='ws-1', title='API Redesign', status='active', repos=['myapp'])
|
|
56
|
+
ws2 = Workstream(id='ws-2', title='API Migration', status='active', repos=['myapp'])
|
|
57
|
+
assert _infer_workstream('myapp', 'api-migration-redesign', [ws1, ws2]) is None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── Full backfill handler ────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def test_backfill_adds_id(tmp_path: Path):
|
|
63
|
+
"""Backfill adds an ID to a plan file that lacks one."""
|
|
64
|
+
# Set up repo with a plan missing an ID
|
|
65
|
+
repo = tmp_path / 'myapp'
|
|
66
|
+
plans_dir = repo / '.plans'
|
|
67
|
+
plans_dir.mkdir(parents=True)
|
|
68
|
+
|
|
69
|
+
plan_text = dedent("""\
|
|
70
|
+
---
|
|
71
|
+
title: My Plan
|
|
72
|
+
status: active
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
Plan content here.
|
|
76
|
+
""")
|
|
77
|
+
(plans_dir / 'my-plan.md').write_text(plan_text, encoding='utf-8')
|
|
78
|
+
|
|
79
|
+
# Set up empty workstreams dir
|
|
80
|
+
ws_dir = tmp_path / 'workstreams'
|
|
81
|
+
ws_dir.mkdir()
|
|
82
|
+
|
|
83
|
+
config = Config(
|
|
84
|
+
workstreams_dir=str(ws_dir),
|
|
85
|
+
repos=[RepoConfig(path=str(repo), name='myapp')],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
from workstream.commands.backfill import _backfill_handler
|
|
90
|
+
_backfill_handler(config=config, dry_run=False)
|
|
91
|
+
|
|
92
|
+
# The original file should be renamed
|
|
93
|
+
remaining = list(plans_dir.glob('*.md'))
|
|
94
|
+
assert len(remaining) == 1
|
|
95
|
+
|
|
96
|
+
new_file = remaining[0]
|
|
97
|
+
# Should have the pattern: <id>-my-plan.md
|
|
98
|
+
assert 'my-plan' in new_file.name
|
|
99
|
+
assert len(new_file.name) > len('my-plan.md') # has ID prefix
|
|
100
|
+
|
|
101
|
+
# Verify frontmatter has an ID
|
|
102
|
+
meta, _ = parse_frontmatter(new_file.read_text(encoding='utf-8'))
|
|
103
|
+
assert 'id' in meta
|
|
104
|
+
assert meta['id'] in new_file.name
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_backfill_dry_run(tmp_path: Path, capsys):
|
|
108
|
+
"""Dry run shows what would change without modifying files."""
|
|
109
|
+
repo = tmp_path / 'myapp'
|
|
110
|
+
plans_dir = repo / '.plans'
|
|
111
|
+
plans_dir.mkdir(parents=True)
|
|
112
|
+
|
|
113
|
+
plan_text = dedent("""\
|
|
114
|
+
---
|
|
115
|
+
title: Another Plan
|
|
116
|
+
status: active
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
Plan content.
|
|
120
|
+
""")
|
|
121
|
+
original_name = 'another-plan.md'
|
|
122
|
+
(plans_dir / original_name).write_text(plan_text, encoding='utf-8')
|
|
123
|
+
|
|
124
|
+
ws_dir = tmp_path / 'workstreams'
|
|
125
|
+
ws_dir.mkdir()
|
|
126
|
+
|
|
127
|
+
config = Config(
|
|
128
|
+
workstreams_dir=str(ws_dir),
|
|
129
|
+
repos=[RepoConfig(path=str(repo), name='myapp')],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
from workstream.commands.backfill import _backfill_handler
|
|
134
|
+
_backfill_handler(config=config, dry_run=True)
|
|
135
|
+
|
|
136
|
+
# Original file should be unchanged
|
|
137
|
+
assert (plans_dir / original_name).exists()
|
|
138
|
+
meta, _ = parse_frontmatter((plans_dir / original_name).read_text(encoding='utf-8'))
|
|
139
|
+
assert 'id' not in meta # not modified
|
|
140
|
+
|
|
141
|
+
out = capsys.readouterr().out
|
|
142
|
+
assert 'would add id' in out
|
|
143
|
+
assert 'dry run' in out.lower()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_backfill_preserves_existing_id(tmp_path: Path):
|
|
147
|
+
"""Plans with existing IDs are not re-IDed."""
|
|
148
|
+
repo = tmp_path / 'myapp'
|
|
149
|
+
plans_dir = repo / '.plans'
|
|
150
|
+
plans_dir.mkdir(parents=True)
|
|
151
|
+
|
|
152
|
+
plan_text = dedent("""\
|
|
153
|
+
---
|
|
154
|
+
id: 2026-03-20-existing123
|
|
155
|
+
title: Existing Plan
|
|
156
|
+
status: active
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
Already has an ID.
|
|
160
|
+
""")
|
|
161
|
+
(plans_dir / '2026-03-20-existing123-existing-plan.md').write_text(plan_text, encoding='utf-8')
|
|
162
|
+
|
|
163
|
+
ws_dir = tmp_path / 'workstreams'
|
|
164
|
+
ws_dir.mkdir()
|
|
165
|
+
|
|
166
|
+
config = Config(
|
|
167
|
+
workstreams_dir=str(ws_dir),
|
|
168
|
+
repos=[RepoConfig(path=str(repo), name='myapp')],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
from workstream.commands.backfill import _backfill_handler
|
|
173
|
+
_backfill_handler(config=config, dry_run=False)
|
|
174
|
+
|
|
175
|
+
# File should still exist with same name
|
|
176
|
+
assert (plans_dir / '2026-03-20-existing123-existing-plan.md').exists()
|
|
177
|
+
meta, _ = parse_frontmatter(
|
|
178
|
+
(plans_dir / '2026-03-20-existing123-existing-plan.md').read_text(encoding='utf-8')
|
|
179
|
+
)
|
|
180
|
+
assert meta['id'] == '2026-03-20-existing123'
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Tests for batch review plan status writeback mechanics."""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
|
|
5
|
+
from workstream.markdown import parse_frontmatter, write_frontmatter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_plan_status_writeback(tmp_path):
|
|
9
|
+
"""Simulate batch review updating a plan file's status."""
|
|
10
|
+
plan_dir = tmp_path / '.plans'
|
|
11
|
+
plan_dir.mkdir()
|
|
12
|
+
plan_file = plan_dir / 'myplan.md'
|
|
13
|
+
plan_file.write_text(
|
|
14
|
+
'---\ntitle: My Plan\nstatus: active\nworkstream: test\n---\n\nPlan content.',
|
|
15
|
+
encoding='utf-8',
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Simulate classification
|
|
19
|
+
cl = {'plan': 'myplan.md', 'status': 'implemented', 'reason': 'Commits match', 'ideas': []}
|
|
20
|
+
today = date.today().isoformat()
|
|
21
|
+
|
|
22
|
+
# Apply the same logic as _batch_review_plans_handler
|
|
23
|
+
plan_text = plan_file.read_text(encoding='utf-8')
|
|
24
|
+
plan_meta, plan_body = parse_frontmatter(plan_text)
|
|
25
|
+
assert plan_meta.get('status') == 'active'
|
|
26
|
+
|
|
27
|
+
if plan_meta and plan_meta.get('status') != cl['status']:
|
|
28
|
+
plan_meta['status'] = cl['status']
|
|
29
|
+
plan_meta['reviewed'] = today
|
|
30
|
+
plan_file.write_text(
|
|
31
|
+
write_frontmatter(plan_meta, plan_body),
|
|
32
|
+
encoding='utf-8',
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Verify
|
|
36
|
+
updated_text = plan_file.read_text(encoding='utf-8')
|
|
37
|
+
updated_meta, _ = parse_frontmatter(updated_text)
|
|
38
|
+
assert updated_meta['status'] == 'implemented'
|
|
39
|
+
assert updated_meta['reviewed'] == today
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_plan_status_writeback_no_change(tmp_path):
|
|
43
|
+
"""Don't write if status already matches."""
|
|
44
|
+
plan_dir = tmp_path / '.plans'
|
|
45
|
+
plan_dir.mkdir()
|
|
46
|
+
plan_file = plan_dir / 'myplan.md'
|
|
47
|
+
original = '---\ntitle: My Plan\nstatus: active\n---\n\nContent.'
|
|
48
|
+
plan_file.write_text(original, encoding='utf-8')
|
|
49
|
+
|
|
50
|
+
cl = {'plan': 'myplan.md', 'status': 'active', 'reason': 'Still active', 'ideas': []}
|
|
51
|
+
|
|
52
|
+
plan_text = plan_file.read_text(encoding='utf-8')
|
|
53
|
+
plan_meta, plan_body = parse_frontmatter(plan_text)
|
|
54
|
+
|
|
55
|
+
# Same status — should not rewrite
|
|
56
|
+
if plan_meta and plan_meta.get('status') != cl['status']:
|
|
57
|
+
plan_meta['status'] = cl['status']
|
|
58
|
+
plan_file.write_text(write_frontmatter(plan_meta, plan_body), encoding='utf-8')
|
|
59
|
+
|
|
60
|
+
# File should be unchanged
|
|
61
|
+
assert plan_file.read_text(encoding='utf-8') == original
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_plan_status_writeback_no_frontmatter(tmp_path):
|
|
65
|
+
"""Plans without frontmatter should be skipped."""
|
|
66
|
+
plan_dir = tmp_path / '.plans'
|
|
67
|
+
plan_dir.mkdir()
|
|
68
|
+
plan_file = plan_dir / 'legacy.md'
|
|
69
|
+
original = '<!-- STATUS: FINALIZED -->\n\nLegacy plan.'
|
|
70
|
+
plan_file.write_text(original, encoding='utf-8')
|
|
71
|
+
|
|
72
|
+
plan_text = plan_file.read_text(encoding='utf-8')
|
|
73
|
+
plan_meta, plan_body = parse_frontmatter(plan_text)
|
|
74
|
+
|
|
75
|
+
# No frontmatter — should skip
|
|
76
|
+
if plan_meta and plan_meta.get('status') != 'implemented':
|
|
77
|
+
plan_meta['status'] = 'implemented'
|
|
78
|
+
plan_file.write_text(write_frontmatter(plan_meta, plan_body), encoding='utf-8')
|
|
79
|
+
|
|
80
|
+
# File unchanged
|
|
81
|
+
assert plan_file.read_text(encoding='utf-8') == original
|