codee-agent 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.
Files changed (69) hide show
  1. codee/.gitignore +2 -0
  2. codee/__init__.py +0 -0
  3. codee/admin.py +1672 -0
  4. codee/admin_api.py +61 -0
  5. codee/admin_cli.py +86 -0
  6. codee/admin_service.py +1005 -0
  7. codee/executor.py +381 -0
  8. codee/init_cli.py +82 -0
  9. codee/lib/__init__.py +0 -0
  10. codee/lib/cron_describe.py +33 -0
  11. codee/lib/runs_db.py +195 -0
  12. codee/lib/test_runs_db.py +199 -0
  13. codee/lib/test_trigger_cron_skills.py +485 -0
  14. codee/lib/test_trigger_issue_skills.py +93 -0
  15. codee/lib/trigger_aws_sqs_skills.py +224 -0
  16. codee/lib/trigger_cron_skills.py +364 -0
  17. codee/lib/trigger_email_skills.py +225 -0
  18. codee/lib/trigger_issue_skills.py +107 -0
  19. codee/mail_server.py +45 -0
  20. codee/start_cli.py +98 -0
  21. codee/templates/AGENTS.md +42 -0
  22. codee/templates/CLAUDE.md +1 -0
  23. codee/templates/skills/aws-sqs-alarm-response/SKILL.md +23 -0
  24. codee/templates/skills/cron-research-5xx-errors/SKILL.md +17 -0
  25. codee/templates/skills/story-code-reviewer/SKILL.md +29 -0
  26. codee/templates/skills/story-developer/SKILL.md +26 -0
  27. codee/templates/skills/story-planner/SKILL.md +35 -0
  28. codee/templates/skills/story-planner/assets/readme-template.md +43 -0
  29. codee/templates/skills/story-qa/SKILL.md +28 -0
  30. codee/templates/skills/task-developer/SKILL.md +25 -0
  31. codee/templates/skills/task-qa/SKILL.md +26 -0
  32. codee/test_admin_api.py +64 -0
  33. codee/test_admin_cli.py +63 -0
  34. codee/test_admin_service.py +897 -0
  35. codee/test_executor.py +190 -0
  36. codee/test_init_cli.py +131 -0
  37. codee/test_memory_index.py +31 -0
  38. codee/test_start_cli.py +164 -0
  39. codee/workflow_graph.py +83 -0
  40. codee_admin/__init__.py +1 -0
  41. codee_admin/codee_admin.py +4 -0
  42. codee_agent-0.1.0.dist-info/METADATA +66 -0
  43. codee_agent-0.1.0.dist-info/RECORD +69 -0
  44. codee_agent-0.1.0.dist-info/WHEEL +4 -0
  45. codee_agent-0.1.0.dist-info/entry_points.txt +6 -0
  46. codee_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. codee_agent_abstract/__init__.py +0 -0
  48. codee_agent_abstract/provider.py +56 -0
  49. codee_agent_claude_code/__init__.py +0 -0
  50. codee_agent_claude_code/provider.py +90 -0
  51. codee_agent_github_copilot/__init__.py +0 -0
  52. codee_agent_github_copilot/provider.py +253 -0
  53. codee_agent_github_copilot/test.py +176 -0
  54. codee_database/__init__.py +0 -0
  55. codee_database/database.py +13 -0
  56. codee_database/oauth_tokens.py +148 -0
  57. codee_main_context/__init__.py +0 -0
  58. codee_main_context/context.py +127 -0
  59. codee_main_context/logging.py +111 -0
  60. codee_main_context/test_logging.py +90 -0
  61. codee_tasks_abstract/__init__.py +0 -0
  62. codee_tasks_abstract/provider.py +58 -0
  63. codee_tasks_azure_devops/__init__.py +0 -0
  64. codee_tasks_azure_devops/oauth.py +346 -0
  65. codee_tasks_azure_devops/provider.py +207 -0
  66. codee_tasks_azure_devops/test.py +462 -0
  67. codee_tasks_jira/__init__.py +0 -0
  68. codee_tasks_jira/provider.py +162 -0
  69. codee_tasks_jira/test.py +75 -0
@@ -0,0 +1,897 @@
1
+ import os
2
+ import tempfile
3
+ import threading
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest.mock import Mock, patch
7
+ from urllib.parse import parse_qs, urlparse
8
+
9
+ from codee_agent_abstract.provider import AgentModel
10
+ from codee_agent_claude_code.provider import ClaudeCodeAgent
11
+ from codee_agent_github_copilot.provider import GitHubCopilotAgent
12
+
13
+ from codee.admin_service import AdminService, azure_oauth, parse_skill
14
+ from codee_main_context.context import (
15
+ CodeeMainContext, CodingAgent, Settings, TasksProvider, save_settings)
16
+
17
+
18
+ class AdminServiceIssueTriggerTest(unittest.TestCase):
19
+ def test_list_skills_includes_issue_status(self) -> None:
20
+ with tempfile.TemporaryDirectory() as temporary_directory:
21
+ skills_dir = Path(temporary_directory)
22
+ skill_dir = skills_dir / "triage"
23
+ skill_dir.mkdir()
24
+ (skill_dir / "SKILL.md").write_text(
25
+ "---\nname: triage\ndescription: Triage issues\n"
26
+ "x-codee-trigger: issue\n"
27
+ "x-codee-issue-status: [Ready, In progress]\n"
28
+ "x-codee-issue-type: story\n---\nBody\n"
29
+ )
30
+ service = AdminService.__new__(AdminService)
31
+ service.skills_dir = skills_dir
32
+
33
+ skills = service.list_skills()
34
+
35
+ self.assertEqual(skills[0]["issue_status"], "Ready, In progress")
36
+ self.assertEqual(skills[0]["issue_type"], "story")
37
+
38
+ def test_save_issue_trigger_writes_required_frontmatter(self) -> None:
39
+ with tempfile.TemporaryDirectory() as temporary_directory:
40
+ skills_dir = Path(temporary_directory)
41
+ skill_dir = skills_dir / "triage"
42
+ skill_dir.mkdir()
43
+ (skill_dir / "SKILL.md").write_text(
44
+ "---\nname: triage\nx-codee-issue-type: story\n---\nBody\n"
45
+ )
46
+ service = AdminService.__new__(AdminService)
47
+ service.skills_dir = skills_dir
48
+
49
+ with patch.object(service, "_write_and_push",
50
+ return_value=(True, True, "saved")) as write:
51
+ ok, _, _, slug = service.save_skill({
52
+ "slug": "triage",
53
+ "name": "triage",
54
+ "description": "Triage matching issues",
55
+ "type": "issue trigger",
56
+ "issue_status": "Ready, In progress",
57
+ "issue_type": "task",
58
+ "body": "Body",
59
+ })
60
+
61
+ frontmatter, _ = parse_skill(write.call_args.args[1])
62
+ self.assertTrue(ok)
63
+ self.assertEqual(slug, "triage")
64
+ self.assertIs(frontmatter["disable-model-invocation"], True)
65
+ self.assertEqual(frontmatter["x-codee-trigger"], "issue")
66
+ self.assertEqual(
67
+ frontmatter["x-codee-issue-status"], ["Ready", "In progress"])
68
+ self.assertEqual(frontmatter["x-codee-issue-type"], "task")
69
+
70
+ def test_save_reports_saved_when_git_push_fails(self) -> None:
71
+ with tempfile.TemporaryDirectory() as temporary_directory:
72
+ root = Path(temporary_directory)
73
+ skills_dir = root / ".claude" / "skills"
74
+ skill_dir = skills_dir / "triage"
75
+ skill_dir.mkdir(parents=True)
76
+ (skill_dir / "SKILL.md").write_text("---\nname: triage\n---\nOld\n")
77
+ service = AdminService.__new__(AdminService)
78
+ service.root = root
79
+ service.skills_dir = skills_dir
80
+
81
+ with patch.object(service, "_git_push", return_value=(False, "no upstream")):
82
+ saved, pushed, message, slug = service.save_skill({
83
+ "slug": "triage",
84
+ "name": "triage",
85
+ "description": "Triage matching issues",
86
+ "type": "knowledge",
87
+ "issue_status": "",
88
+ "issue_type": "",
89
+ "body": "New body",
90
+ })
91
+
92
+ self.assertTrue(saved)
93
+ self.assertFalse(pushed)
94
+ self.assertIn("Git push failed", message)
95
+ self.assertEqual(slug, "triage")
96
+ self.assertIn("New body", (skill_dir / "SKILL.md").read_text())
97
+ self.assertEqual(
98
+ service.list_skills()[0]["description"], "Triage matching issues")
99
+
100
+ def test_load_issue_trigger_includes_issue_type(self) -> None:
101
+ with tempfile.TemporaryDirectory() as temporary_directory:
102
+ skills_dir = Path(temporary_directory)
103
+ skill_dir = skills_dir / "triage"
104
+ skill_dir.mkdir()
105
+ (skill_dir / "SKILL.md").write_text(
106
+ "---\nname: triage\nx-codee-trigger: issue\n"
107
+ "x-codee-issue-type: story\n---\nBody\n"
108
+ )
109
+ service = AdminService.__new__(AdminService)
110
+ service.skills_dir = skills_dir
111
+
112
+ skill = service.load_skill("triage")
113
+
114
+ self.assertEqual(skill["issue_type"], "story")
115
+
116
+ def test_delete_skill_removes_directory_and_pushes(self) -> None:
117
+ with tempfile.TemporaryDirectory() as temporary_directory:
118
+ skills_dir = Path(temporary_directory)
119
+ skill_dir = skills_dir / "triage"
120
+ skill_dir.mkdir()
121
+ (skill_dir / "SKILL.md").write_text("---\nname: triage\n---\nBody\n")
122
+ service = AdminService.__new__(AdminService)
123
+ service.skills_dir = skills_dir
124
+
125
+ with patch.object(service, "_git_push", return_value=(True, "")) as push:
126
+ deleted, pushed, message = service.delete_skill("triage")
127
+
128
+ self.assertTrue(deleted)
129
+ self.assertTrue(pushed)
130
+ self.assertEqual(message, "Deleted triage")
131
+ self.assertFalse(skill_dir.exists())
132
+ self.assertEqual(push.call_args.args[0], "skill: delete triage")
133
+
134
+ def test_delete_skill_reports_deleted_when_git_push_fails(self) -> None:
135
+ with tempfile.TemporaryDirectory() as temporary_directory:
136
+ skills_dir = Path(temporary_directory)
137
+ skill_dir = skills_dir / "triage"
138
+ skill_dir.mkdir()
139
+ (skill_dir / "SKILL.md").write_text("---\nname: triage\n---\nBody\n")
140
+ service = AdminService.__new__(AdminService)
141
+ service.skills_dir = skills_dir
142
+
143
+ with patch.object(service, "_git_push", return_value=(False, "no upstream")):
144
+ deleted, pushed, message = service.delete_skill("triage")
145
+
146
+ self.assertTrue(deleted)
147
+ self.assertFalse(pushed)
148
+ self.assertIn("Git push failed", message)
149
+ self.assertFalse(skill_dir.exists())
150
+
151
+ def test_delete_skill_rejects_unknown_slug(self) -> None:
152
+ with tempfile.TemporaryDirectory() as temporary_directory:
153
+ service = AdminService.__new__(AdminService)
154
+ service.skills_dir = Path(temporary_directory)
155
+
156
+ with patch.object(service, "_git_push") as push:
157
+ deleted, pushed, message = service.delete_skill("missing")
158
+
159
+ self.assertFalse(deleted)
160
+ self.assertFalse(pushed)
161
+ self.assertEqual(message, "missing does not exist")
162
+ push.assert_not_called()
163
+
164
+ def test_resolve_skill_slug_matches_name_and_directory(self) -> None:
165
+ with tempfile.TemporaryDirectory() as temporary_directory:
166
+ skills_dir = Path(temporary_directory)
167
+ skill_dir = skills_dir / "task-developer"
168
+ skill_dir.mkdir()
169
+ (skill_dir / "SKILL.md").write_text(
170
+ "---\nname: Task Developer\ndescription: Build tasks\n---\nBody\n"
171
+ )
172
+ service = AdminService.__new__(AdminService)
173
+ service.skills_dir = skills_dir
174
+
175
+ self.assertEqual(
176
+ service.resolve_skill_slug("Task Developer"), "task-developer")
177
+ self.assertEqual(
178
+ service.resolve_skill_slug(" task-developer "), "task-developer")
179
+ self.assertEqual(service.resolve_skill_slug("missing"), "")
180
+ self.assertEqual(service.resolve_skill_slug(""), "")
181
+
182
+ def test_generate_workflow_builds_react_flow_graph_from_issue_skills(self) -> None:
183
+ with tempfile.TemporaryDirectory() as temporary_directory:
184
+ root = Path(temporary_directory)
185
+ skills_dir = root / ".claude" / "skills"
186
+ skill_dir = skills_dir / "develop"
187
+ skill_dir.mkdir(parents=True)
188
+ (skill_dir / "SKILL.md").write_text(
189
+ "---\nname: develop\ndisable-model-invocation: true\n"
190
+ "x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
191
+ "x-codee-issue-type: story\n---\n"
192
+ "After implementation, move the issue to Review.\n"
193
+ )
194
+ task_develop_dir = skills_dir / "task-develop"
195
+ task_develop_dir.mkdir()
196
+ (task_develop_dir / "SKILL.md").write_text(
197
+ "---\nname: task-develop\ndisable-model-invocation: true\n"
198
+ "x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
199
+ "x-codee-issue-type: task\n---\n"
200
+ "When implementation is complete, move the task to Review.\n"
201
+ )
202
+ review_dir = skills_dir / "review"
203
+ review_dir.mkdir()
204
+ (review_dir / "SKILL.md").write_text(
205
+ "---\nname: review\ndisable-model-invocation: true\n"
206
+ "x-codee-trigger: issue\nx-codee-issue-status: [Review]\n"
207
+ "x-codee-issue-type: story\n---\n"
208
+ "After approval, move the issue to Done.\n"
209
+ "When fixes are needed, move the issue to Ready.\n"
210
+ )
211
+ agent = Mock()
212
+ agent.run.side_effect = [
213
+ '```json\n{"statuses":["Ready","Review","Done"],'
214
+ '"transitions":[{"source":"Ready","target":"Review",'
215
+ '"label":"develop","evidence":"After implementation, move the issue to Review."},'
216
+ '{"source":"Review","target":"Done","label":"review",'
217
+ '"evidence":"After approval, move the issue to Done."},'
218
+ '{"source":"Review","target":"Ready","label":"review",'
219
+ '"evidence":"When fixes are needed, move the issue to Ready."}],'
220
+ '"final_statuses":["Done"]}\n```',
221
+ '{"statuses":["Ready","Review"],"transitions":['
222
+ '{"source":"Ready","target":"Review","label":"task-develop",'
223
+ '"evidence":"When implementation is complete, move the task to Review."}],'
224
+ '"final_statuses":["Review"]}',
225
+ ]
226
+ agent_type = Mock(return_value=agent)
227
+ service = AdminService.__new__(AdminService)
228
+ service.root = root
229
+ service.skills_dir = skills_dir
230
+ service.context = Mock(settings=Settings(
231
+ coding_agent=CodingAgent.CLAUDE_CODE))
232
+
233
+ with patch.dict("codee.admin_service._CODING_AGENTS", {
234
+ CodingAgent.CLAUDE_CODE: agent_type,
235
+ }):
236
+ workflows = service.generate_workflow()
237
+
238
+ workflow = workflows["story"]
239
+
240
+ self.assertEqual(
241
+ [
242
+ node["data"]["label"] for node in workflow["nodes"]
243
+ if node["data"]["label"]
244
+ ],
245
+ ["Ready", "Review", "Done"],
246
+ )
247
+ self.assertEqual(len(workflow["edges"]), 4)
248
+ self.assertEqual(
249
+ workflow["edges"][0]["data"]["skills"],
250
+ ["develop"],
251
+ )
252
+ self.assertEqual(
253
+ workflow["edges"][0]["label"],
254
+ "develop",
255
+ )
256
+ self.assertEqual(
257
+ workflow["edges"][0]["ariaLabel"],
258
+ "Ready to Review via develop",
259
+ )
260
+ self.assertEqual(workflow["nodes"][1]
261
+ ["position"], {"x": 440, "y": 0})
262
+ self.assertEqual(workflow["nodes"][1]["sourcePosition"], "right")
263
+ route_node = workflow["nodes"][3]
264
+ self.assertEqual(route_node["id"], "return-route-0")
265
+ self.assertGreater(route_node["position"]["y"], 0)
266
+ self.assertEqual(route_node["style"]["opacity"], 1)
267
+ self.assertIn("workflow-route-node--return",
268
+ route_node["className"])
269
+ self.assertIs(workflow["edges"][2]["animated"], True)
270
+ self.assertEqual(
271
+ workflow["edges"][2]["className"],
272
+ "workflow-edge workflow-edge--return",
273
+ )
274
+ self.assertEqual(
275
+ workflow["edges"][2]["style"]["strokeDasharray"], "8 6")
276
+ self.assertEqual(workflow["edges"][2]["target"], route_node["id"])
277
+ self.assertEqual(workflow["edges"][2]["label"], "review")
278
+ self.assertEqual(workflow["edges"][3]["source"], route_node["id"])
279
+ self.assertEqual(workflow["edges"][3]["target"], "status-0")
280
+ self.assertNotIn("label", workflow["edges"][3])
281
+ self.assertNotIn("markerEnd", workflow["edges"][2])
282
+ self.assertEqual(workflow["warnings"], [])
283
+ story_prompt = agent.run.call_args_list[0].args[0]
284
+ task_prompt = agent.run.call_args_list[1].args[0]
285
+ self.assertIn("Build the story workflow", story_prompt)
286
+ self.assertIn("move the issue to Review", story_prompt)
287
+ self.assertNotIn("task-develop", story_prompt)
288
+ self.assertIn("Build the task workflow", task_prompt)
289
+ self.assertIn("task-develop", task_prompt)
290
+ self.assertNotIn("## Skill: develop", task_prompt)
291
+ self.assertEqual(
292
+ workflows["task"]["edges"][0]["data"]["skills"],
293
+ ["task-develop"],
294
+ )
295
+
296
+ def test_generate_workflow_warns_when_final_status_is_missing(self) -> None:
297
+ with tempfile.TemporaryDirectory() as temporary_directory:
298
+ root = Path(temporary_directory)
299
+ skills_dir = root / ".claude" / "skills"
300
+ skill_dir = skills_dir / "develop"
301
+ skill_dir.mkdir(parents=True)
302
+ (skill_dir / "SKILL.md").write_text(
303
+ "---\nname: develop\ndisable-model-invocation: true\n"
304
+ "x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
305
+ "x-codee-issue-type: story\n---\n"
306
+ "Keep the issue Ready while work remains.\n"
307
+ )
308
+ agent = Mock()
309
+ agent.run.return_value = (
310
+ '{"statuses":["Ready"],"transitions":['
311
+ '{"source":"Ready","target":"Ready","label":"develop",'
312
+ '"evidence":"Keep the issue Ready while work remains."}],'
313
+ '"final_statuses":[]}'
314
+ )
315
+ service = AdminService.__new__(AdminService)
316
+ service.root = root
317
+ service.skills_dir = skills_dir
318
+ service.context = Mock(
319
+ settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
320
+
321
+ with patch.dict("codee.admin_service._CODING_AGENTS", {
322
+ CodingAgent.CLAUDE_CODE: Mock(return_value=agent),
323
+ }):
324
+ workflow = service.generate_workflow()["story"]
325
+
326
+ self.assertEqual(workflow["warnings"], [
327
+ "No final human-handoff status is defined in the issue skill workflow.",
328
+ ])
329
+
330
+ def test_generate_workflow_removes_skill_edge_bypassing_intermediate_status(self) -> None:
331
+ with tempfile.TemporaryDirectory() as temporary_directory:
332
+ root = Path(temporary_directory)
333
+ skills_dir = root / ".claude" / "skills"
334
+ skill_dir = skills_dir / "story-developer"
335
+ skill_dir.mkdir(parents=True)
336
+ (skill_dir / "SKILL.md").write_text(
337
+ "---\nname: story-developer\ndisable-model-invocation: true\n"
338
+ "x-codee-trigger: issue\n"
339
+ "x-codee-issue-status: ['[AI] Ready for development', '[AI] In Progress']\n"
340
+ "x-codee-issue-type: story\n"
341
+ "---\nMake sure story in [AI] In Progress status before doing any work.\n"
342
+ "After task is complete move it to [AI] CR Needed.\n"
343
+ )
344
+ agent = Mock()
345
+ agent.run.return_value = (
346
+ '{"statuses":["[AI] Ready for development","[AI] In Progress",'
347
+ '"[AI] CR Needed"],"transitions":['
348
+ '{"source":"[AI] Ready for development","target":"[AI] In Progress",'
349
+ '"label":"story-developer","evidence":"Make sure story in [AI] In Progress status before doing any work."},'
350
+ '{"source":"[AI] In Progress","target":"[AI] CR Needed",'
351
+ '"label":"story-developer","evidence":"After task is complete move it to [AI] CR Needed."},'
352
+ '{"source":"[AI] Ready for development","target":"[AI] CR Needed",'
353
+ '"label":"story-developer","evidence":"After task is complete move it to [AI] CR Needed."}],'
354
+ '"final_statuses":["[AI] CR Needed"]}'
355
+ )
356
+ service = AdminService.__new__(AdminService)
357
+ service.root = root
358
+ service.skills_dir = skills_dir
359
+ service.context = Mock(
360
+ settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
361
+
362
+ with patch.dict("codee.admin_service._CODING_AGENTS", {
363
+ CodingAgent.CLAUDE_CODE: Mock(return_value=agent),
364
+ }):
365
+ workflow = service.generate_workflow()["story"]
366
+
367
+ self.assertEqual(
368
+ [(edge["source"], edge["target"])
369
+ for edge in workflow["edges"]],
370
+ [("status-0", "status-1"), ("status-1", "status-2")],
371
+ )
372
+ self.assertIn(
373
+ "do not emit a direct transition that bypasses it",
374
+ agent.run.call_args.args[0],
375
+ )
376
+
377
+ def test_generate_workflow_retries_unsupported_transition(self) -> None:
378
+ with tempfile.TemporaryDirectory() as temporary_directory:
379
+ root = Path(temporary_directory)
380
+ skills_dir = root / ".claude" / "skills"
381
+ skill_dir = skills_dir / "story-planner"
382
+ skill_dir.mkdir(parents=True)
383
+ (skill_dir / "SKILL.md").write_text(
384
+ "---\nname: story-planner\ndisable-model-invocation: true\n"
385
+ "x-codee-trigger: issue\n"
386
+ "x-codee-issue-status: ['[AI] Decomposition needed']\n"
387
+ "x-codee-issue-type: story\n---\n"
388
+ "After planning, move the story to [AI] Ready for human review.\n"
389
+ )
390
+ agent = Mock()
391
+ agent.run.side_effect = [
392
+ '{"statuses":["[AI] Decomposition needed",'
393
+ '"[AI] Ready for development","[AI] Ready for human review"],'
394
+ '"transitions":[{"source":"[AI] Decomposition needed",'
395
+ '"target":"[AI] Ready for development","label":"",'
396
+ '"evidence":""}],"final_statuses":[]}',
397
+ '{"statuses":["[AI] Decomposition needed",'
398
+ '"[AI] Ready for development","[AI] Ready for human review"],'
399
+ '"transitions":[{'
400
+ '"source":"[AI] Decomposition needed",'
401
+ '"target":"[AI] Ready for human review",'
402
+ '"label":"story-planner",'
403
+ '"evidence":"After planning, move the story to [AI] Ready for human review."}],'
404
+ '"final_statuses":["[AI] Ready for human review"]}',
405
+ ]
406
+ service = AdminService.__new__(AdminService)
407
+ service.root = root
408
+ service.skills_dir = skills_dir
409
+ service.context = Mock(
410
+ settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
411
+
412
+ with patch.dict("codee.admin_service._CODING_AGENTS", {
413
+ CodingAgent.CLAUDE_CODE: Mock(return_value=agent),
414
+ }):
415
+ workflow = service.generate_workflow()["story"]
416
+
417
+ self.assertEqual(agent.run.call_count, 2)
418
+ self.assertIn(
419
+ "each transition label must name its defining skill",
420
+ agent.run.call_args.args[0],
421
+ )
422
+ route_out, route_in = workflow["nodes"][3:]
423
+ self.assertEqual(route_out["id"], "forward-route-0-out")
424
+ self.assertEqual(route_in["id"], "forward-route-0-in")
425
+ self.assertIn("workflow-route-node--forward",
426
+ route_out["className"])
427
+ self.assertEqual(route_out["sourcePosition"], "right")
428
+ self.assertEqual(route_out["targetPosition"], "left")
429
+ self.assertEqual(route_in["sourcePosition"], "right")
430
+ self.assertEqual(route_in["targetPosition"], "left")
431
+ self.assertEqual(route_out["style"]["width"], 1)
432
+ self.assertEqual(route_out["style"]["height"], 1)
433
+ self.assertLess(route_out["position"]["y"], 0)
434
+ self.assertEqual(
435
+ route_out["position"]["y"], route_in["position"]["y"])
436
+ self.assertEqual(workflow["edges"][0]["label"], "story-planner")
437
+ self.assertEqual(workflow["edges"][0]["target"], route_out["id"])
438
+ self.assertEqual(workflow["edges"][1]["source"], route_out["id"])
439
+ self.assertEqual(workflow["edges"][1]["target"], route_in["id"])
440
+ self.assertNotIn("label", workflow["edges"][1])
441
+ self.assertEqual(workflow["edges"][2]["source"], route_in["id"])
442
+ self.assertEqual(workflow["edges"][2]["target"], "status-2")
443
+
444
+ def test_generate_workflow_warns_when_statuses_have_no_edges(self) -> None:
445
+ with tempfile.TemporaryDirectory() as temporary_directory:
446
+ root = Path(temporary_directory)
447
+ skills_dir = root / ".claude" / "skills"
448
+ skill_dir = skills_dir / "develop"
449
+ skill_dir.mkdir(parents=True)
450
+ (skill_dir / "SKILL.md").write_text(
451
+ "---\nname: develop\ndisable-model-invocation: true\n"
452
+ "x-codee-trigger: issue\n"
453
+ "x-codee-issue-status: [Ready, In Progress, Review]\n"
454
+ "x-codee-issue-type: story\n---\n"
455
+ "Work on issues in the configured statuses.\n"
456
+ )
457
+ agent = Mock()
458
+ agent.run.return_value = (
459
+ '{"statuses":["Ready","In Progress","Review"],'
460
+ '"transitions":[],"final_statuses":["Review"]}'
461
+ )
462
+ service = AdminService.__new__(AdminService)
463
+ service.root = root
464
+ service.skills_dir = skills_dir
465
+ service.context = Mock(
466
+ settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
467
+
468
+ with patch.dict("codee.admin_service._CODING_AGENTS", {
469
+ CodingAgent.CLAUDE_CODE: Mock(return_value=agent),
470
+ }):
471
+ workflow = service.generate_workflow()["story"]
472
+
473
+ self.assertEqual(workflow["warnings"], [
474
+ "Workflow statuses are disconnected: no status transitions were found.",
475
+ ])
476
+ self.assertEqual(workflow["edges"], [])
477
+ self.assertTrue(all(
478
+ "workflow-node--disconnected" in node["className"]
479
+ for node in workflow["nodes"]
480
+ ))
481
+
482
+ def test_generate_workflow_flags_unhandled_status_on_its_node(self) -> None:
483
+ with tempfile.TemporaryDirectory() as temporary_directory:
484
+ root = Path(temporary_directory)
485
+ skills_dir = root / ".claude" / "skills"
486
+ skill_dir = skills_dir / "develop"
487
+ skill_dir.mkdir(parents=True)
488
+ (skill_dir / "SKILL.md").write_text(
489
+ "---\nname: develop\ndisable-model-invocation: true\n"
490
+ "x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
491
+ "x-codee-issue-type: story\n---\n"
492
+ "Move the issue to In Progress when work starts.\n"
493
+ )
494
+ agent = Mock()
495
+ agent.run.return_value = (
496
+ '{"statuses":["Ready","In Progress","Done"],"transitions":['
497
+ '{"source":"Ready","target":"In Progress","label":"develop",'
498
+ '"evidence":"Move the issue to In Progress when work starts."}],'
499
+ '"final_statuses":["Done"]}'
500
+ )
501
+ service = AdminService.__new__(AdminService)
502
+ service.root = root
503
+ service.skills_dir = skills_dir
504
+ service.context = Mock(
505
+ settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
506
+
507
+ with patch.dict("codee.admin_service._CODING_AGENTS", {
508
+ CodingAgent.CLAUDE_CODE: Mock(return_value=agent),
509
+ }):
510
+ workflow = service.generate_workflow()["story"]
511
+
512
+ self.assertEqual(workflow["warnings"], [])
513
+ flagged = [
514
+ node["data"]["label"] for node in workflow["nodes"]
515
+ if "workflow-node--unhandled" in node.get("className", "")
516
+ ]
517
+ self.assertEqual(flagged, ["In Progress"])
518
+
519
+ def test_generate_workflow_returns_empty_without_issue_skills(self) -> None:
520
+ with tempfile.TemporaryDirectory() as temporary_directory:
521
+ service = AdminService.__new__(AdminService)
522
+ service.skills_dir = Path(temporary_directory)
523
+
524
+ self.assertEqual(service.generate_workflow(), {
525
+ "story": {"nodes": [], "edges": [], "warnings": []},
526
+ "task": {"nodes": [], "edges": [], "warnings": []},
527
+ })
528
+
529
+ def test_generate_workflow_is_cached_until_forced(self) -> None:
530
+ service = AdminService.__new__(AdminService)
531
+ generated = {"nodes": [], "edges": [], "warnings": []}
532
+
533
+ with patch.object(service, "_generate_workflow", return_value=generated) as generate:
534
+ self.assertIs(service.generate_workflow(), generated)
535
+ self.assertIs(service.generate_workflow(), generated)
536
+ self.assertIs(service.generate_workflow(force=True), generated)
537
+
538
+ self.assertEqual(generate.call_count, 2)
539
+
540
+
541
+ class AdminServiceSkillModelTest(unittest.TestCase):
542
+ def _service(self, skills_dir: Path, frontmatter: str) -> AdminService:
543
+ skill_dir = skills_dir / "nightly"
544
+ skill_dir.mkdir()
545
+ (skill_dir / "SKILL.md").write_text(f"---\n{frontmatter}---\nBody\n")
546
+ service = AdminService.__new__(AdminService)
547
+ service.skills_dir = skills_dir
548
+ return service
549
+
550
+ def test_save_writes_the_model_frontmatter(self) -> None:
551
+ with tempfile.TemporaryDirectory() as temporary_directory:
552
+ skills_dir = Path(temporary_directory)
553
+ service = self._service(skills_dir, "name: nightly\n")
554
+
555
+ with patch.object(service, "_write_and_push",
556
+ return_value=(True, True, "saved")) as write:
557
+ service.save_skill({
558
+ "slug": "nightly", "name": "nightly", "description": "",
559
+ "type": "knowledge", "model": "claude-opus-5",
560
+ "body": "Body",
561
+ })
562
+
563
+ frontmatter, _ = parse_skill(write.call_args.args[1])
564
+ self.assertEqual(frontmatter["model"], "claude-opus-5")
565
+
566
+ def test_an_empty_model_is_left_out_of_the_frontmatter(self) -> None:
567
+ with tempfile.TemporaryDirectory() as temporary_directory:
568
+ skills_dir = Path(temporary_directory)
569
+ service = self._service(skills_dir, "name: nightly\n")
570
+
571
+ with patch.object(service, "_write_and_push",
572
+ return_value=(True, True, "saved")) as write:
573
+ service.save_skill({
574
+ "slug": "nightly", "name": "nightly", "description": "",
575
+ "type": "knowledge", "model": " ", "body": "Body",
576
+ })
577
+
578
+ frontmatter, _ = parse_skill(write.call_args.args[1])
579
+ self.assertNotIn("model", frontmatter)
580
+
581
+ def test_load_returns_the_model_and_keeps_it_out_of_preserved_extras(self) -> None:
582
+ with tempfile.TemporaryDirectory() as temporary_directory:
583
+ skills_dir = Path(temporary_directory)
584
+ service = self._service(
585
+ skills_dir, "name: nightly\nmodel: claude-opus-5\nlicense: MIT\n")
586
+
587
+ skill = service.load_skill("nightly")
588
+
589
+ self.assertEqual(skill["model"], "claude-opus-5")
590
+ # Managed keys are rewritten on save, so only `license` is carried over.
591
+ self.assertEqual(skill["extra"], "license: MIT\n")
592
+
593
+ def test_load_reports_no_model_when_the_skill_declares_none(self) -> None:
594
+ with tempfile.TemporaryDirectory() as temporary_directory:
595
+ skills_dir = Path(temporary_directory)
596
+ service = self._service(skills_dir, "name: nightly\n")
597
+
598
+ self.assertEqual(service.load_skill("nightly")["model"], "")
599
+
600
+
601
+ class AdminServiceSkillExtraFrontmatterTest(unittest.TestCase):
602
+ def _service(self, skills_dir: Path, frontmatter: str) -> AdminService:
603
+ skill_dir = skills_dir / "nightly"
604
+ skill_dir.mkdir()
605
+ (skill_dir / "SKILL.md").write_text(f"---\n{frontmatter}---\nBody\n")
606
+ service = AdminService.__new__(AdminService)
607
+ service.skills_dir = skills_dir
608
+ return service
609
+
610
+ def _save(self, service: AdminService, extra: str | None) -> tuple[Mock, tuple]:
611
+ skill: dict[str, str] = {
612
+ "slug": "nightly", "name": "nightly", "description": "",
613
+ "type": "knowledge", "model": "", "body": "Body",
614
+ }
615
+ if extra is not None:
616
+ skill["extra"] = extra
617
+ with patch.object(service, "_write_and_push",
618
+ return_value=(True, True, "saved")) as write:
619
+ result = service.save_skill(skill)
620
+ return write, result
621
+
622
+ def test_save_writes_the_extra_fields_into_the_frontmatter(self) -> None:
623
+ with tempfile.TemporaryDirectory() as temporary_directory:
624
+ service = self._service(Path(temporary_directory), "name: nightly\n")
625
+
626
+ write, _ = self._save(
627
+ service, "allowed-tools: Bash\ncompatibility: Claude Code\n")
628
+
629
+ frontmatter, _ = parse_skill(write.call_args.args[1])
630
+ self.assertEqual(frontmatter["allowed-tools"], "Bash")
631
+ self.assertEqual(frontmatter["compatibility"], "Claude Code")
632
+
633
+ def test_empty_extra_fields_drop_what_the_file_carried(self) -> None:
634
+ with tempfile.TemporaryDirectory() as temporary_directory:
635
+ service = self._service(
636
+ Path(temporary_directory), "name: nightly\nlicense: MIT\n")
637
+
638
+ write, _ = self._save(service, "")
639
+
640
+ frontmatter, _ = parse_skill(write.call_args.args[1])
641
+ self.assertNotIn("license", frontmatter)
642
+
643
+ def test_a_caller_that_omits_extra_keeps_the_fields_on_file(self) -> None:
644
+ with tempfile.TemporaryDirectory() as temporary_directory:
645
+ service = self._service(
646
+ Path(temporary_directory), "name: nightly\nlicense: MIT\n")
647
+
648
+ write, _ = self._save(service, None)
649
+
650
+ frontmatter, _ = parse_skill(write.call_args.args[1])
651
+ self.assertEqual(frontmatter["license"], "MIT")
652
+
653
+ def test_invalid_yaml_is_reported_and_nothing_is_written(self) -> None:
654
+ with tempfile.TemporaryDirectory() as temporary_directory:
655
+ service = self._service(Path(temporary_directory), "name: nightly\n")
656
+
657
+ write, (saved, _, message, slug) = self._save(service, "allowed-tools")
658
+
659
+ write.assert_not_called()
660
+ self.assertFalse(saved)
661
+ self.assertIn("key: value", message)
662
+ self.assertEqual(slug, "nightly")
663
+
664
+ def test_a_field_that_has_its_own_control_is_rejected(self) -> None:
665
+ with tempfile.TemporaryDirectory() as temporary_directory:
666
+ service = self._service(Path(temporary_directory), "name: nightly\n")
667
+
668
+ write, (saved, _, message, _) = self._save(
669
+ service, "model: claude-opus-5\n")
670
+
671
+ write.assert_not_called()
672
+ self.assertFalse(saved)
673
+ self.assertIn("model", message)
674
+
675
+ def test_load_returns_the_extra_fields_as_yaml(self) -> None:
676
+ with tempfile.TemporaryDirectory() as temporary_directory:
677
+ service = self._service(
678
+ Path(temporary_directory),
679
+ "name: nightly\nallowed-tools: Bash\nlicense: MIT\n")
680
+
681
+ self.assertEqual(service.load_skill("nightly")["extra"],
682
+ "allowed-tools: Bash\nlicense: MIT\n")
683
+
684
+ def test_load_returns_an_empty_string_when_there_are_none(self) -> None:
685
+ with tempfile.TemporaryDirectory() as temporary_directory:
686
+ service = self._service(Path(temporary_directory), "name: nightly\n")
687
+
688
+ self.assertEqual(service.load_skill("nightly")["extra"], "")
689
+
690
+
691
+ class AdminServiceAgentModelsTest(unittest.TestCase):
692
+ def _service(self, agent: CodingAgent) -> AdminService:
693
+ service = AdminService.__new__(AdminService)
694
+ service.context = CodeeMainContext(
695
+ data_dir=Path("/tmp"), settings=Settings(coding_agent=agent))
696
+ service._models_cache = {}
697
+ service._models_lock = threading.Lock()
698
+ return service
699
+
700
+ def test_lists_the_configured_agents_models_once_and_caches_them(self) -> None:
701
+ service = self._service(CodingAgent.CLAUDE_CODE)
702
+
703
+ with patch.object(ClaudeCodeAgent, "list_models",
704
+ return_value=[AgentModel("claude-opus-5", "Claude Opus 5")]
705
+ ) as list_models:
706
+ first = service.list_agent_models()
707
+ second = service.list_agent_models()
708
+
709
+ self.assertEqual(first, [{"id": "claude-opus-5", "name": "Claude Opus 5"}])
710
+ self.assertEqual(second, first)
711
+ list_models.assert_called_once()
712
+
713
+ def test_an_agent_that_cannot_be_asked_yields_an_empty_list(self) -> None:
714
+ service = self._service(CodingAgent.GITHUB_COPILOT)
715
+
716
+ with patch.object(GitHubCopilotAgent, "list_models",
717
+ side_effect=RuntimeError("copilot is not logged in")):
718
+ self.assertEqual(service.list_agent_models(), [])
719
+
720
+
721
+ class AdminServiceAgentsFileTest(unittest.TestCase):
722
+ def _service(self, root: Path) -> AdminService:
723
+ service = AdminService.__new__(AdminService)
724
+ service.root = root
725
+ service.skills_dir = root / ".claude" / "skills"
726
+ service.memory_dir = root / "memory"
727
+ service.agents_file = root / "AGENTS.md"
728
+ return service
729
+
730
+ def test_load_agents_returns_file_text(self) -> None:
731
+ with tempfile.TemporaryDirectory() as temporary_directory:
732
+ root = Path(temporary_directory)
733
+ (root / "AGENTS.md").write_text("---\nnot: frontmatter\n---\nBody\n")
734
+ service = self._service(root)
735
+
736
+ self.assertEqual(service.load_agents(),
737
+ "---\nnot: frontmatter\n---\nBody\n")
738
+
739
+ def test_load_agents_returns_empty_string_when_missing(self) -> None:
740
+ with tempfile.TemporaryDirectory() as temporary_directory:
741
+ service = self._service(Path(temporary_directory))
742
+
743
+ self.assertEqual(service.load_agents(), "")
744
+
745
+ def test_save_agents_writes_text_and_pushes(self) -> None:
746
+ with tempfile.TemporaryDirectory() as temporary_directory:
747
+ root = Path(temporary_directory)
748
+ (root / "AGENTS.md").write_text("Old\n")
749
+ service = self._service(root)
750
+
751
+ with patch.object(service, "_git_push", return_value=(True, "")) as push:
752
+ saved, pushed, message = service.save_agents("New rules\n")
753
+
754
+ self.assertTrue(saved)
755
+ self.assertTrue(pushed)
756
+ self.assertIn("AGENTS.md", message)
757
+ self.assertEqual((root / "AGENTS.md").read_text(), "New rules\n")
758
+ push.assert_called_once_with("agents: update AGENTS.md")
759
+
760
+ def test_git_push_stages_agents_file_only_when_present(self) -> None:
761
+ with tempfile.TemporaryDirectory() as temporary_directory:
762
+ root = Path(temporary_directory)
763
+ service = self._service(root)
764
+
765
+ with patch("codee.admin_service.subprocess.run") as run:
766
+ run.return_value = Mock(returncode=0, stdout="", stderr="")
767
+ service._git_push("skill: update triage")
768
+ without_agents = run.call_args_list[0].args[0]
769
+
770
+ (root / "AGENTS.md").write_text("Rules\n")
771
+ run.reset_mock()
772
+ service._git_push("agents: update AGENTS.md")
773
+ with_agents = run.call_args_list[0].args[0]
774
+
775
+ self.assertNotIn(str(root / "AGENTS.md"), without_agents)
776
+ self.assertIn(str(root / "AGENTS.md"), with_agents)
777
+
778
+
779
+ class AzureDevOpsOAuthTest(unittest.TestCase):
780
+ """The admin side of the flow: build the URL, then handle the callback."""
781
+
782
+ AZURE_CREDENTIALS = {
783
+ "organization_url": "https://dev.azure.com/acme",
784
+ "project": "Core",
785
+ "tenant_id": "tenant-1",
786
+ "client_id": "client-1",
787
+ "client_secret": "secret-1",
788
+ }
789
+
790
+ def _service(self, data_dir: Path, credentials: dict | None = None) -> AdminService:
791
+ service = AdminService.__new__(AdminService)
792
+ service.data_dir = data_dir
793
+ settings = Settings(
794
+ tasks_provider=TasksProvider.AZURE_DEVOPS,
795
+ credentials={"azure_devops": credentials
796
+ if credentials is not None else self.AZURE_CREDENTIALS})
797
+ service.context = CodeeMainContext(data_dir=data_dir, settings=settings)
798
+ save_settings(data_dir, settings)
799
+ return service
800
+
801
+ def test_redirect_uri_follows_the_admin_port(self) -> None:
802
+ with tempfile.TemporaryDirectory() as temporary_directory:
803
+ service = self._service(Path(temporary_directory))
804
+
805
+ with patch.dict(os.environ, {"REFLEX_API_URL": "http://127.0.0.1:9100",
806
+ "CODEE_ADMIN_BASE_URL": ""}, clear=False):
807
+ self.assertEqual(service.azure_redirect_uri(),
808
+ "http://localhost:9100/api/oauth/azure-devops/callback")
809
+
810
+ def test_redirect_uri_honours_an_explicit_base_url(self) -> None:
811
+ with tempfile.TemporaryDirectory() as temporary_directory:
812
+ service = self._service(Path(temporary_directory))
813
+
814
+ with patch.dict(os.environ,
815
+ {"CODEE_ADMIN_BASE_URL": "https://codee.example.com/"},
816
+ clear=False):
817
+ self.assertEqual(
818
+ service.azure_redirect_uri(),
819
+ "https://codee.example.com/api/oauth/azure-devops/callback")
820
+
821
+ def test_authorization_refuses_an_incomplete_app_registration(self) -> None:
822
+ with tempfile.TemporaryDirectory() as temporary_directory:
823
+ service = self._service(Path(temporary_directory),
824
+ credentials={"organization_url": "https://dev.azure.com/acme"})
825
+
826
+ started, message = service.start_azure_authorization()
827
+
828
+ self.assertFalse(started)
829
+ self.assertIn("client secret", message)
830
+
831
+ def test_callback_stores_the_tokens_for_the_state_it_issued(self) -> None:
832
+ with tempfile.TemporaryDirectory() as temporary_directory:
833
+ service = self._service(Path(temporary_directory))
834
+ started, url = service.start_azure_authorization()
835
+ state = parse_qs(urlparse(url).query)["state"][0]
836
+ tokens = {"access_token": "at", "refresh_token": "rt",
837
+ "expires_at": "2026-08-05T12:00:00+00:00", "scope": "s"}
838
+
839
+ with patch.object(azure_oauth, "exchange_code", return_value=tokens) as exchange, \
840
+ patch.object(azure_oauth, "fetch_account", return_value="dev@acme.com"):
841
+ connected, message = service.complete_azure_authorization("code-1", state)
842
+
843
+ self.assertTrue(started)
844
+ self.assertTrue(connected)
845
+ self.assertIn("dev@acme.com", message)
846
+ # The exchange must reuse the redirect URI the authorization was issued
847
+ # with — Entra rejects the code otherwise.
848
+ self.assertEqual(exchange.call_args.args[1], service.azure_redirect_uri())
849
+ self.assertTrue(service.azure_connection()["connected"])
850
+
851
+ def test_callback_with_a_forged_state_stores_nothing(self) -> None:
852
+ with tempfile.TemporaryDirectory() as temporary_directory:
853
+ service = self._service(Path(temporary_directory))
854
+ service.start_azure_authorization()
855
+
856
+ with patch.object(azure_oauth, "exchange_code") as exchange:
857
+ connected, message = service.complete_azure_authorization(
858
+ "code-1", "forged-state")
859
+
860
+ exchange.assert_not_called()
861
+ self.assertFalse(connected)
862
+ self.assertIn("again", message)
863
+ self.assertFalse(service.azure_connection()["connected"])
864
+
865
+ def test_failed_exchange_reports_the_entra_message(self) -> None:
866
+ with tempfile.TemporaryDirectory() as temporary_directory:
867
+ service = self._service(Path(temporary_directory))
868
+ _, url = service.start_azure_authorization()
869
+ state = parse_qs(urlparse(url).query)["state"][0]
870
+
871
+ with patch.object(azure_oauth, "exchange_code",
872
+ side_effect=azure_oauth.AzureDevOpsAuthError(
873
+ "AADSTS7000215: Invalid client secret.")):
874
+ connected, message = service.complete_azure_authorization("code-1", state)
875
+
876
+ self.assertFalse(connected)
877
+ self.assertIn("Invalid client secret", message)
878
+ self.assertFalse(service.azure_connection()["connected"])
879
+
880
+ def test_disconnect_forgets_the_tokens(self) -> None:
881
+ with tempfile.TemporaryDirectory() as temporary_directory:
882
+ service = self._service(Path(temporary_directory))
883
+ _, url = service.start_azure_authorization()
884
+ state = parse_qs(urlparse(url).query)["state"][0]
885
+ with patch.object(azure_oauth, "exchange_code",
886
+ return_value={"access_token": "at", "refresh_token": "rt",
887
+ "expires_at": None, "scope": ""}), \
888
+ patch.object(azure_oauth, "fetch_account", return_value=""):
889
+ service.complete_azure_authorization("code-1", state)
890
+
891
+ service.disconnect_azure()
892
+
893
+ self.assertFalse(service.azure_connection()["connected"])
894
+
895
+
896
+ if __name__ == "__main__":
897
+ unittest.main()