main
py 375 lines 16.4 KB
Raw
1 import io
2 import json
3 import tempfile
4 import unittest
5 from pathlib import Path
6 from unittest import mock
7
8 import scripts.reskill as reskill
9
10
11 class _FakeHTTPResponse(io.BytesIO):
12 def __enter__(self):
13 return self
14
15 def __exit__(self, exc_type, exc, tb):
16 self.close()
17 return False
18
19
20 class ReskillTests(unittest.TestCase):
21 def test_render_prompt_includes_recent_context(self) -> None:
22 tests_root = Path(__file__).resolve().parent
23 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
24 base = Path(tmpdir)
25 analyzed_dir = base / "data" / "analyzed"
26 snapshots_dir = base / "data" / "snapshots"
27 content_root = base / "content"
28 wisdom_path = base / ".squad" / "identity" / "wisdom.md"
29 skills_dir = base / ".squad" / "skills" / "trend-detection"
30 continuity_path = base / ".squad" / "topics" / "ai-ml" / "continuity.md"
31 prompt_template = base / "reskill.md"
32 output_path = base / ".squad" / "reskill" / "2026-W21.md"
33 analyzed_dir.mkdir(parents=True)
34 snapshots_dir.mkdir(parents=True)
35 wisdom_path.parent.mkdir(parents=True)
36 skills_dir.mkdir(parents=True)
37 continuity_path.parent.mkdir(parents=True)
38 (content_root / "monthly" / "2026").mkdir(parents=True)
39 (content_root / "yearly").mkdir(parents=True)
40 output_path.parent.mkdir(parents=True)
41
42 for week, score in [
43 ("2026-W17", 61),
44 ("2026-W18", 66),
45 ("2026-W19", 70),
46 ("2026-W20", 74),
47 ("2026-W21", 79),
48 ("2026-W22", 84),
49 ]:
50 (analyzed_dir / f"{week}-summary.md").write_text(
51 f"---\nweek: {week}\nquality_score: {score}\n---\n\n## Trend Analysis\n\n### Signal\n\nSignal {week}.\n",
52 encoding="utf-8",
53 )
54 (snapshots_dir / "2026-W21-stars.json").write_text(
55 json.dumps({"octo/signal-kit": 120}), encoding="utf-8"
56 )
57 wisdom_path.write_text("# Wisdom\n\nPrefer durable signals.", encoding="utf-8")
58 (skills_dir / "SKILL.md").write_text(
59 "# Skill\n\nWatch for wrapper churn.", encoding="utf-8"
60 )
61 continuity_path.write_text(
62 "# Continuity\n\nMonthly theses that held up.", encoding="utf-8"
63 )
64 (content_root / "monthly" / "2026" / "05.md").write_text(
65 "## Month Overview\n\nMonthly context.\n", encoding="utf-8"
66 )
67 (content_root / "yearly" / "2026.md").write_text(
68 "## Narrative\n\nYearly context.\n", encoding="utf-8"
69 )
70 prompt_template.write_text(
71 "out={{OUTPUT_PATH}}\nwisdom={{WISDOM}}\nskills={{SKILLS}}\ncontinuity={{CONTINUITY}}\narchive={{ARCHIVE_CONTEXT}}\nquality={{QUALITY_TREND}}\nanalyses={{RECENT_ANALYSES}}\nsnapshots={{SNAPSHOT_CONTEXT}}\n",
72 encoding="utf-8",
73 )
74
75 prompt = reskill.render_prompt(
76 prompt_template_path=prompt_template,
77 current_datetime="2026-05-18T15:22:25.067+02:00",
78 output_path=output_path,
79 analyzed_dir=analyzed_dir,
80 snapshots_dir=snapshots_dir,
81 wisdom_file=wisdom_path,
82 skills_dir=base / ".squad" / "skills",
83 continuity_file=continuity_path,
84 content_root=content_root,
85 limit=5,
86 )
87
88 self.assertIn(f"out={output_path}", prompt)
89 self.assertIn("Prefer durable signals.", prompt)
90 self.assertIn("Watch for wrapper churn.", prompt)
91 self.assertIn("Monthly theses that held up.", prompt)
92 self.assertIn("Monthly context.", prompt)
93 self.assertIn("Yearly context.", prompt)
94 self.assertIn("Average quality score", prompt)
95 self.assertNotIn("2026-W17-summary.md", prompt)
96 self.assertIn("2026-W18-summary.md", prompt)
97 self.assertIn("2026-W21-stars.json", prompt)
98 self.assertIn("No snapshot data available for hindsight validation.", prompt)
99
100 def test_render_prompt_resolves_topic_context_defaults(self) -> None:
101 tests_root = Path(__file__).resolve().parent
102 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
103 base = Path(tmpdir)
104 prompt_template = base / "reskill.md"
105 output_path = base / ".squad" / "reskill" / "2026-W21.md"
106 analyzed_dir = base / "data" / "analyzed"
107 snapshots_dir = base / "data" / "snapshots"
108 content_root = base / "content"
109 topic_wisdom = base / ".squad" / "topics" / "ai-ml" / "wisdom.md"
110 topic_skills = base / ".squad" / "topics" / "ai-ml" / "skills"
111 topic_continuity = base / ".squad" / "topics" / "ai-ml" / "continuity.md"
112 analyzed_dir.mkdir(parents=True)
113 snapshots_dir.mkdir(parents=True)
114 topic_wisdom.parent.mkdir(parents=True)
115 topic_skills.mkdir(parents=True)
116 prompt_template.write_text(
117 "w={{WISDOM}}\ns={{SKILLS}}\nc={{CONTINUITY}}", encoding="utf-8"
118 )
119 topic_wisdom.write_text("Topic wisdom", encoding="utf-8")
120 (topic_skills / "SKILL.md").write_text("Topic skill", encoding="utf-8")
121 topic_continuity.write_text("Topic continuity", encoding="utf-8")
122
123 with mock.patch.object(
124 reskill,
125 "resolve_analysis_context_paths",
126 return_value=(topic_wisdom, topic_skills, topic_continuity),
127 ) as resolver:
128 prompt = reskill.render_prompt(
129 prompt_template_path=prompt_template,
130 current_datetime="2026-05-18T15:22:25.067+02:00",
131 output_path=output_path,
132 analyzed_dir=analyzed_dir,
133 snapshots_dir=snapshots_dir,
134 wisdom_file=reskill.DEFAULT_WISDOM_FILE,
135 skills_dir=reskill.DEFAULT_SKILLS_DIR,
136 continuity_file=reskill.DEFAULT_CONTINUITY_FILE,
137 content_root=content_root,
138 limit=5,
139 )
140
141 resolver.assert_called_once_with()
142 self.assertIn("Topic wisdom", prompt)
143 self.assertIn("Topic skill", prompt)
144 self.assertIn("Topic continuity", prompt)
145
146 def test_render_archive_context_caps_monthly_and_yearly_sections(self) -> None:
147 tests_root = Path(__file__).resolve().parent
148 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
149 content_root = Path(tmpdir) / "content"
150 (content_root / "monthly" / "2026").mkdir(parents=True)
151 (content_root / "yearly").mkdir(parents=True)
152 (content_root / "monthly" / "2026" / "05.md").write_text(
153 "## Month Overview\n\n" + " ".join(["monthlytoken"] * 260),
154 encoding="utf-8",
155 )
156 (content_root / "yearly" / "2026.md").write_text(
157 "## Narrative\n\n" + " ".join(["yearlytoken"] * 620),
158 encoding="utf-8",
159 )
160
161 prompt = reskill.render_archive_context("2026-05-18T15:22:25.067+02:00", content_root)
162
163 self.assertLessEqual(prompt.count("monthlytoken"), reskill.ARCHIVE_MONTHLY_MAX_WORDS)
164 self.assertLessEqual(prompt.count("yearlytoken"), reskill.ARCHIVE_YEARLY_MAX_WORDS)
165 self.assertIn("", prompt)
166
167 def test_main_writes_default_weekly_report(self) -> None:
168 tests_root = Path(__file__).resolve().parent
169 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
170 base = Path(tmpdir)
171 analyzed_dir = base / "data" / "analyzed"
172 snapshots_dir = base / "data" / "snapshots"
173 wisdom_path = base / ".squad" / "identity" / "wisdom.md"
174 skills_dir = base / ".squad" / "skills"
175 prompt_template = base / "reskill.md"
176 analyzed_dir.mkdir(parents=True)
177 snapshots_dir.mkdir(parents=True)
178 wisdom_path.parent.mkdir(parents=True)
179 skills_dir.mkdir(parents=True)
180 (analyzed_dir / "2026-W21-summary.md").write_text(
181 "---\nweek: 2026-W21\nquality_score: 76\n---\n\nBody\n",
182 encoding="utf-8",
183 )
184 wisdom_path.write_text("# Wisdom\n\nPrefer durable signals.", encoding="utf-8")
185 prompt_template.write_text("{{WISDOM}}\n{{QUALITY_TREND}}", encoding="utf-8")
186
187 response = _FakeHTTPResponse(
188 json.dumps({"choices": [{"message": {"content": "# Reskill Report\n"}}]}).encode(
189 "utf-8"
190 )
191 )
192
193 with (
194 mock.patch.object(reskill, "DEFAULT_REPORT_DIR", base / ".squad" / "reskill"),
195 mock.patch.dict("os.environ", {"GITHUB_TOKEN": "token"}, clear=False),
196 mock.patch.object(reskill.request, "urlopen", return_value=response),
197 ):
198 exit_code = reskill.main(
199 [
200 "--current-datetime",
201 "2026-05-18T15:22:25.067+02:00",
202 "--prompt-template",
203 str(prompt_template),
204 "--analyzed-dir",
205 str(analyzed_dir),
206 "--snapshots-dir",
207 str(snapshots_dir),
208 "--wisdom-file",
209 str(wisdom_path),
210 "--skills-dir",
211 str(skills_dir),
212 ]
213 )
214
215 self.assertEqual(exit_code, 0)
216 output_path = base / ".squad" / "reskill" / "2026-W21.md"
217 self.assertEqual(output_path.read_text(encoding="utf-8"), "# Reskill Report\n")
218
219 def test_main_can_write_prompt_output_sidecar(self) -> None:
220 tests_root = Path(__file__).resolve().parent
221 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
222 base = Path(tmpdir)
223 analyzed_dir = base / "data" / "analyzed"
224 snapshots_dir = base / "data" / "snapshots"
225 wisdom_path = base / ".squad" / "identity" / "wisdom.md"
226 skills_dir = base / ".squad" / "skills"
227 prompt_template = base / "reskill.md"
228 output_path = base / ".squad" / "reskill" / "2026-W21.md"
229 prompt_output_path = base / "tmp" / "reskill-prompt.txt"
230 analyzed_dir.mkdir(parents=True)
231 snapshots_dir.mkdir(parents=True)
232 wisdom_path.parent.mkdir(parents=True)
233 skills_dir.mkdir(parents=True)
234 output_path.parent.mkdir(parents=True)
235 (analyzed_dir / "2026-W21-summary.md").write_text(
236 "---\nweek: 2026-W21\nquality_score: 76\n---\n\nBody\n",
237 encoding="utf-8",
238 )
239 wisdom_path.write_text("# Wisdom\n\nPrefer durable signals.", encoding="utf-8")
240 prompt_template.write_text("{{WISDOM}}\n{{QUALITY_TREND}}", encoding="utf-8")
241
242 response = _FakeHTTPResponse(
243 json.dumps({"choices": [{"message": {"content": "# Reskill Report\n"}}]}).encode(
244 "utf-8"
245 )
246 )
247
248 with (
249 mock.patch.dict("os.environ", {"GITHUB_TOKEN": "token"}, clear=False),
250 mock.patch.object(reskill.request, "urlopen", return_value=response),
251 ):
252 exit_code = reskill.main(
253 [
254 "--current-datetime",
255 "2026-05-18T15:22:25.067+02:00",
256 "--prompt-template",
257 str(prompt_template),
258 "--analyzed-dir",
259 str(analyzed_dir),
260 "--snapshots-dir",
261 str(snapshots_dir),
262 "--wisdom-file",
263 str(wisdom_path),
264 "--skills-dir",
265 str(skills_dir),
266 "--output",
267 str(output_path),
268 "--prompt-output",
269 str(prompt_output_path),
270 ]
271 )
272
273 self.assertEqual(exit_code, 0)
274 self.assertTrue(prompt_output_path.exists())
275 self.assertIn("Prefer durable signals.", prompt_output_path.read_text(encoding="utf-8"))
276
277 def test_main_handles_model_403_gracefully(self) -> None:
278 """When GitHub Models returns 403 (no model access), main exits 0 with a placeholder."""
279 tests_root = Path(__file__).resolve().parent
280 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
281 base = Path(tmpdir)
282 analyzed_dir = base / "data" / "analyzed"
283 snapshots_dir = base / "data" / "snapshots"
284 wisdom_path = base / ".squad" / "identity" / "wisdom.md"
285 skills_dir = base / ".squad" / "skills"
286 prompt_template = base / "reskill.md"
287 output_path = base / ".squad" / "reskill" / "2026-W21.md"
288 analyzed_dir.mkdir(parents=True)
289 snapshots_dir.mkdir(parents=True)
290 wisdom_path.parent.mkdir(parents=True)
291 skills_dir.mkdir(parents=True)
292 output_path.parent.mkdir(parents=True)
293 wisdom_path.write_text("# Wisdom\n\nPrefer durable signals.", encoding="utf-8")
294 prompt_template.write_text("{{WISDOM}}\n{{QUALITY_TREND}}", encoding="utf-8")
295
296 import io as _io
297 from urllib import error as urlerror
298
299 fake_body = _io.BytesIO(
300 b'{"error":{"code":"no_access","message":"No access to model: openai/gpt-4.1"}}'
301 )
302 http_err = urlerror.HTTPError(
303 url="https://models.github.ai",
304 code=403,
305 msg="Forbidden",
306 hdrs={}, # type: ignore[arg-type]
307 fp=fake_body,
308 )
309
310 with (
311 mock.patch.dict("os.environ", {"GITHUB_TOKEN": "token"}, clear=False),
312 mock.patch.object(reskill.request, "urlopen", side_effect=http_err),
313 ):
314 exit_code = reskill.main(
315 [
316 "--current-datetime",
317 "2026-05-18T15:22:25.067+02:00",
318 "--prompt-template",
319 str(prompt_template),
320 "--analyzed-dir",
321 str(analyzed_dir),
322 "--snapshots-dir",
323 str(snapshots_dir),
324 "--wisdom-file",
325 str(wisdom_path),
326 "--skills-dir",
327 str(skills_dir),
328 "--output",
329 str(output_path),
330 ]
331 )
332
333 self.assertEqual(exit_code, 0)
334 content = output_path.read_text(encoding="utf-8")
335 self.assertIn("Reskill skipped", content)
336 self.assertIn("403", content)
337
338 def test_github_models_endpoint_rejects_non_allowlisted_host(self) -> None:
339 with mock.patch.dict(
340 "os.environ",
341 {"GITHUB_TOKEN": "token", "GITHUB_MODELS_ENDPOINT": "https://evil.example.com/v1/chat"},
342 clear=False,
343 ):
344 with self.assertRaisesRegex(ValueError, "host must be one of"):
345 reskill.call_github_models("prompt")
346
347 def test_github_models_endpoint_accepts_allowlisted_host(self) -> None:
348 class _FakeResponse(io.BytesIO):
349 def __enter__(self):
350 return self
351
352 def __exit__(self, *_):
353 self.close()
354 return False
355
356 response = _FakeResponse(
357 json.dumps({"choices": [{"message": {"content": "# Reskill\n"}}]}).encode("utf-8")
358 )
359 with (
360 mock.patch.dict(
361 "os.environ",
362 {
363 "GITHUB_TOKEN": "token",
364 "GITHUB_MODELS_ENDPOINT": reskill.DEFAULT_MODELS_ENDPOINT,
365 },
366 clear=False,
367 ),
368 mock.patch.object(reskill.request, "urlopen", return_value=response),
369 ):
370 markdown = reskill.call_github_models("prompt")
371 self.assertEqual(markdown, "# Reskill\n")
372
373
374 if __name__ == "__main__":
375 unittest.main()