codee-agent 0.2.0__tar.gz → 0.4.0__tar.gz

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 (68) hide show
  1. {codee_agent-0.2.0 → codee_agent-0.4.0}/PKG-INFO +1 -1
  2. {codee_agent-0.2.0 → codee_agent-0.4.0}/pyproject.toml +1 -1
  3. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/admin.py +160 -31
  4. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/admin_cli.py +2 -2
  5. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/admin_service.py +224 -7
  6. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_admin_service.py +229 -9
  7. {codee_agent-0.2.0 → codee_agent-0.4.0}/LICENSE +0 -0
  8. {codee_agent-0.2.0 → codee_agent-0.4.0}/README.md +0 -0
  9. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/.gitignore +0 -0
  10. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/__init__.py +0 -0
  11. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/admin_api.py +0 -0
  12. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/executor.py +0 -0
  13. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/init_cli.py +0 -0
  14. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/__init__.py +0 -0
  15. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/cron_describe.py +0 -0
  16. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/runs_db.py +0 -0
  17. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/test_runs_db.py +0 -0
  18. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  19. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  20. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
  21. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/trigger_cron_skills.py +0 -0
  22. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/trigger_email_skills.py +0 -0
  23. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/trigger_issue_skills.py +0 -0
  24. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/mail_server.py +0 -0
  25. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/start_cli.py +0 -0
  26. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/AGENTS.md +0 -0
  27. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/CLAUDE.md +0 -0
  28. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  29. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  30. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  31. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  32. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  33. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  34. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  35. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  36. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  37. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_admin_api.py +0 -0
  38. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_admin_cli.py +0 -0
  39. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_executor.py +0 -0
  40. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_init_cli.py +0 -0
  41. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_memory_index.py +0 -0
  42. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_start_cli.py +0 -0
  43. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/workflow_graph.py +0 -0
  44. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_admin/__init__.py +0 -0
  45. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_admin/codee_admin.py +0 -0
  46. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_abstract/__init__.py +0 -0
  47. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_abstract/provider.py +0 -0
  48. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_claude_code/__init__.py +0 -0
  49. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_claude_code/provider.py +0 -0
  50. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_github_copilot/__init__.py +0 -0
  51. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_github_copilot/provider.py +0 -0
  52. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_github_copilot/test.py +0 -0
  53. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_database/__init__.py +0 -0
  54. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_database/database.py +0 -0
  55. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_database/oauth_tokens.py +0 -0
  56. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_main_context/__init__.py +0 -0
  57. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_main_context/context.py +0 -0
  58. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_main_context/logging.py +0 -0
  59. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_main_context/test_logging.py +0 -0
  60. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_abstract/__init__.py +0 -0
  61. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_abstract/provider.py +0 -0
  62. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/__init__.py +0 -0
  63. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/oauth.py +0 -0
  64. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/provider.py +0 -0
  65. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/test.py +0 -0
  66. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_jira/__init__.py +0 -0
  67. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_jira/provider.py +0 -0
  68. {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_jira/test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codee-agent
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents.
5
5
  Keywords: ai,agent,jira,azure-devops,claude-code,github-copilot,automation
6
6
  Author: Denis Kibalko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codee-agent"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -48,6 +48,12 @@ class MemoryEntry(BaseModel):
48
48
  matched: bool
49
49
 
50
50
 
51
+ class RepositorySummary(BaseModel):
52
+ name: str
53
+ url: str
54
+ default_branch: str
55
+
56
+
51
57
  class ActiveJob(BaseModel):
52
58
  message: str
53
59
  elapsed_label: str
@@ -95,6 +101,10 @@ class AdminState(rx.State):
95
101
  selected_memory: str = ""
96
102
  memory_content: str = ""
97
103
 
104
+ repositories: list[RepositorySummary] = []
105
+ new_repository_url: str = ""
106
+ adding_repository: bool = False
107
+
98
108
  active_jobs: list[ActiveJob] = []
99
109
  total_runs: int = 0
100
110
  last_24h_runs: int = 0
@@ -325,6 +335,11 @@ class AdminState(rx.State):
325
335
  self.agents_content = SERVICE.load_agents()
326
336
  self.editing_agents = True
327
337
 
338
+ def open_agents_editor(self) -> Any:
339
+ """Open the AGENTS.md editor from a page that isn't Skills."""
340
+ self.edit_agents()
341
+ return rx.redirect("/skills")
342
+
328
343
  def set_agents_content(self, value: str) -> None:
329
344
  self.agents_content = value
330
345
 
@@ -335,6 +350,11 @@ class AdminState(rx.State):
335
350
  return _save_toast(*SERVICE.save_agents(self.agents_content))
336
351
 
337
352
  def load_memories(self) -> None:
353
+ self.load_coding_agent()
354
+ if self.coding_agent == "github_copilot":
355
+ self.memories = []
356
+ self.selected_memory = ""
357
+ return
338
358
  self.memories = [MemoryEntry(**entry)
339
359
  for entry in SERVICE.list_memories()]
340
360
 
@@ -362,6 +382,33 @@ class AdminState(rx.State):
362
382
  self.load_memories()
363
383
  return _save_toast(deleted, pushed, message)
364
384
 
385
+ def load_repositories(self) -> None:
386
+ self.repositories = [RepositorySummary(**repository)
387
+ for repository in SERVICE.list_repositories()]
388
+
389
+ def set_new_repository_url(self, value: str) -> None:
390
+ self.new_repository_url = value
391
+
392
+ @rx.event(background=True)
393
+ async def add_repository(self) -> Any:
394
+ """Clone in the background: a first clone can take minutes."""
395
+ async with self:
396
+ if self.adding_repository:
397
+ return
398
+ url = self.new_repository_url.strip()
399
+ self.adding_repository = True
400
+ try:
401
+ added, message, _ = await asyncio.to_thread(
402
+ SERVICE.add_repository, url)
403
+ except Exception as error:
404
+ added, message = False, str(error)
405
+ async with self:
406
+ self.adding_repository = False
407
+ if added:
408
+ self.new_repository_url = ""
409
+ self.load_repositories()
410
+ yield rx.toast.success(message) if added else rx.toast.error(message)
411
+
365
412
  def _refresh_dashboard(self) -> None:
366
413
  dashboard = SERVICE.dashboard()
367
414
  self.active_jobs = [
@@ -471,6 +518,14 @@ class AdminState(rx.State):
471
518
  self.edit_skill(slug)
472
519
  return rx.redirect("/skills")
473
520
 
521
+ def load_coding_agent(self) -> None:
522
+ """Refresh the agent the sidebar keys off, on every page load.
523
+
524
+ Without this the default sticks until a page that reads settings is
525
+ opened, so the Sessions link shows for Github Copilot.
526
+ """
527
+ self.coding_agent = SERVICE.load_settings().coding_agent.value
528
+
474
529
  def load_settings(self) -> Any:
475
530
  settings = SERVICE.load_settings()
476
531
  self.tasks_provider = settings.tasks_provider.value
@@ -605,6 +660,7 @@ class AdminState(rx.State):
605
660
 
606
661
 
607
662
  ACCENT = "var(--codee-accent)"
663
+ ACCENT_DEEP = "var(--codee-accent-deep)"
608
664
  BORDER = "1px solid var(--codee-border)"
609
665
  MUTED = "var(--codee-muted)"
610
666
  SURFACE = "var(--codee-surface)"
@@ -676,6 +732,8 @@ def shell(content: rx.Component) -> rx.Component:
676
732
  nav_link("Skills", "blocks", "/skills"),
677
733
  nav_link("Workflow", "git-branch", "/workflow"),
678
734
  nav_link("Memory", "notebook-text", "/memory"),
735
+ nav_link("Repositories", "folder-git-2",
736
+ "/repositories"),
679
737
  nav_link("Runs", "history", "/runs"),
680
738
  rx.cond(
681
739
  AdminState.coding_agent == "claude_code",
@@ -706,23 +764,26 @@ def shell(content: rx.Component) -> rx.Component:
706
764
  color=TEXT,
707
765
  font_family="IBM Plex Sans, sans-serif",
708
766
  style={
709
- "--codee-accent": rx.color_mode_cond("#167d5a", "#4abd85"),
710
- "--codee-border": rx.color_mode_cond("#dfe4df", "#354039"),
711
- "--codee-muted": rx.color_mode_cond("#68716b", "#a1ada5"),
712
- "--codee-surface": rx.color_mode_cond("#ffffff", "#18201c"),
713
- "--codee-page-background": rx.color_mode_cond("#f3f5f3", "#101512"),
714
- "--codee-nav-background": rx.color_mode_cond("#f7f9f7", "#141b17"),
715
- "--codee-text": rx.color_mode_cond("#202721", "#edf3ef"),
716
- "--codee-hover": rx.color_mode_cond("#edf5f0", "#213029"),
717
- "--codee-active": rx.color_mode_cond("#e2efe9", "#23372e"),
718
- "--codee-grid": rx.color_mode_cond("#e4e9e5", "#2e3932"),
719
- "--codee-subtle-icon": rx.color_mode_cond("#91a097", "#718078"),
720
- "--codee-warning-background": rx.color_mode_cond("#fffbeb", "#332a13"),
721
- "--codee-warning-border": rx.color_mode_cond("#d97706", "#f59e0b"),
767
+ "--codee-accent": rx.color_mode_cond("#2f6fd6", "#7aa9f0"),
768
+ # The darker half of the two-blue brand pairing, for filled marks
769
+ # that should recede behind the interactive blue (step markers).
770
+ "--codee-accent-deep": rx.color_mode_cond("#232c9b", "#5866de"),
771
+ "--codee-border": rx.color_mode_cond("#dbe1ec", "#2b3350"),
772
+ "--codee-muted": rx.color_mode_cond("#5d687f", "#a3adc4"),
773
+ "--codee-surface": rx.color_mode_cond("#ffffff", "#171d33"),
774
+ "--codee-page-background": rx.color_mode_cond("#f2f5fb", "#0e1224"),
775
+ "--codee-nav-background": rx.color_mode_cond("#f7f9fd", "#121729"),
776
+ "--codee-text": rx.color_mode_cond("#1b2440", "#e9edf7"),
777
+ "--codee-hover": rx.color_mode_cond("#edf3fc", "#1e2742"),
778
+ "--codee-active": rx.color_mode_cond("#e0eafb", "#24304f"),
779
+ "--codee-grid": rx.color_mode_cond("#e2e7f1", "#2a3350"),
780
+ "--codee-subtle-icon": rx.color_mode_cond("#8e99b0", "#6e7a94"),
781
+ "--codee-warning-background": rx.color_mode_cond("#fff5ed", "#33200f"),
782
+ "--codee-warning-border": rx.color_mode_cond("#e26128", "#f5854a"),
722
783
  # Tint reserved for in-flight work, so a live run reads at a glance.
723
- "--codee-running-background": rx.color_mode_cond("#effaf4", "#15271f"),
784
+ "--codee-running-background": rx.color_mode_cond("#eff4fd", "#14203a"),
724
785
  "--codee-running-glow": rx.color_mode_cond(
725
- "rgba(22, 125, 90, 0.16)", "rgba(74, 189, 133, 0.18)"),
786
+ "rgba(47, 111, 214, 0.16)", "rgba(122, 169, 240, 0.18)"),
726
787
  "color_scheme": "light dark",
727
788
  },
728
789
  )
@@ -753,7 +814,8 @@ def live_dot(size: str = "0.6rem") -> rx.Component:
753
814
  return rx.box(
754
815
  rx.box(position="absolute", inset="0", border_radius="50%", background=ACCENT,
755
816
  animation="codee-ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite"),
756
- rx.box(position="absolute", inset="0", border_radius="50%", background=ACCENT),
817
+ rx.box(position="absolute", inset="0",
818
+ border_radius="50%", background=ACCENT),
757
819
  class_name="codee-live-dot",
758
820
  position="relative",
759
821
  width=size,
@@ -765,7 +827,8 @@ def live_dot(size: str = "0.6rem") -> rx.Component:
765
827
  def elapsed_pill(label: rx.Var | str) -> rx.Component:
766
828
  return rx.hstack(
767
829
  rx.icon("timer", size=14, color=ACCENT),
768
- rx.text(label, font_family=MONO, font_size="0.85rem", font_weight="500"),
830
+ rx.text(label, font_family=MONO,
831
+ font_size="0.85rem", font_weight="500"),
769
832
  spacing="2",
770
833
  align="center",
771
834
  flex_shrink="0",
@@ -888,13 +951,13 @@ def skill_card(skill: SkillSummary) -> rx.Component:
888
951
  return rx.box(
889
952
  rx.vstack(
890
953
  rx.hstack(rx.heading(skill.name, size="4"), rx.spacer(),
891
- rx.badge(skill.type, color_scheme="green", variant="soft"), width="100%"),
954
+ rx.badge(skill.type, color_scheme="blue", variant="soft"), width="100%"),
892
955
  rx.text(skill.description, color=MUTED, font_size="0.9rem", min_height="2.7rem",
893
956
  overflow="hidden"),
894
957
  rx.cond(
895
958
  skill.issue_status != "",
896
959
  rx.hstack(
897
- rx.badge(skill.issue_type, color_scheme="green",
960
+ rx.badge(skill.issue_type, color_scheme="blue",
898
961
  variant="outline"),
899
962
  rx.icon("circle-dot", size=14, color=SUBTLE_ICON),
900
963
  rx.text(skill.issue_status, color=MUTED,
@@ -1192,12 +1255,73 @@ def memory_page() -> rx.Component:
1192
1255
  rx.vstack(rx.foreach(AdminState.memories,
1193
1256
  memory_row), spacing="3", width="100%"),
1194
1257
  empty_state("notebook-text", "No memories yet."))
1195
- return shell(rx.vstack(page_header("Memory", "Review and maintain durable project context."),
1196
- rx.cond(AdminState.selected_memory ==
1197
- "", listing, memory_editor()),
1258
+ return shell(rx.vstack(page_header("Memory", "Manage agent provider's memory."),
1259
+ rx.cond(AdminState.coding_agent == "github_copilot",
1260
+ empty_state(
1261
+ "notebook-text",
1262
+ "Github Copilot currently does not support local memory"),
1263
+ rx.cond(AdminState.selected_memory ==
1264
+ "", listing, memory_editor())),
1198
1265
  align="start", width="100%"))
1199
1266
 
1200
1267
 
1268
+ def repository_row(repository: RepositorySummary) -> rx.Component:
1269
+ return rx.flex(
1270
+ rx.icon("folder-git-2", size=18, color=SUBTLE_ICON),
1271
+ rx.vstack(
1272
+ rx.hstack(rx.text(repository.name, font_weight="600"),
1273
+ rx.cond(repository.default_branch != "",
1274
+ rx.badge(rx.icon("house", size=12),
1275
+ repository.default_branch,
1276
+ color_scheme="blue", variant="soft",
1277
+ custom_attrs={"title": "Default branch"})),
1278
+ spacing="2", align="center"),
1279
+ rx.cond(repository.url != "",
1280
+ rx.text(repository.url, color=MUTED, font_family=MONO,
1281
+ font_size="0.8rem")),
1282
+ spacing="2", align="start", flex="1", min_width="0"),
1283
+ gap="0.85rem", align="start", padding="1rem", background=SURFACE,
1284
+ border=BORDER, width="100%")
1285
+
1286
+
1287
+ def repositories_page() -> rx.Component:
1288
+ listing = rx.cond(
1289
+ AdminState.repositories.length() > 0,
1290
+ rx.vstack(rx.foreach(AdminState.repositories, repository_row),
1291
+ spacing="3", width="100%"),
1292
+ empty_state("folder-git-2", "No repositories cloned yet."))
1293
+ return shell(rx.vstack(
1294
+ page_header("Repositories",
1295
+ "Repositories the coding agents work in."),
1296
+ rx.box(
1297
+ rx.flex(
1298
+ rx.input(placeholder="git@github.com:org/repo.git",
1299
+ value=AdminState.new_repository_url,
1300
+ on_change=AdminState.set_new_repository_url,
1301
+ flex="1"),
1302
+ rx.button(rx.icon("plus", size=16), "Add repository",
1303
+ loading=AdminState.adding_repository,
1304
+ on_click=AdminState.add_repository),
1305
+ gap="0.75rem", width="100%"),
1306
+ rx.text("Clones a bare repository into repositories/<name>/.bare "
1307
+ "and checks out its default branch as a worktree beside it. "
1308
+ "A first clone can take a few minutes.",
1309
+ color=MUTED, font_size="0.82rem", margin_top="0.75rem"),
1310
+ padding="1.25rem", background=SURFACE, border=BORDER, width="100%"),
1311
+ rx.callout(
1312
+ rx.fragment(
1313
+ "The agents work better when they know what each repository "
1314
+ "is for: describe them in ",
1315
+ rx.link(AGENTS_FILE, on_click=AdminState.open_agents_editor,
1316
+ color=ACCENT, cursor="pointer",
1317
+ text_decoration="underline"),
1318
+ ".",
1319
+ ),
1320
+ icon="info", size="1", color_scheme="gray", width="100%"),
1321
+ listing,
1322
+ spacing="5", align="start", width="100%"))
1323
+
1324
+
1201
1325
  def run_row(run: RunRecord) -> rx.Component:
1202
1326
  return rx.box(
1203
1327
  rx.flex(
@@ -1239,7 +1363,7 @@ def workflow_warning(message: rx.Var) -> rx.Component:
1239
1363
  return rx.callout(
1240
1364
  message,
1241
1365
  icon="triangle-alert",
1242
- color_scheme="amber",
1366
+ color_scheme="orange",
1243
1367
  width="100%",
1244
1368
  )
1245
1369
 
@@ -1393,7 +1517,7 @@ def sessions_page() -> rx.Component:
1393
1517
 
1394
1518
  def azure_step(number: int, title: str, detail: rx.Component | str) -> rx.Component:
1395
1519
  return rx.hstack(
1396
- rx.box(str(number), color="white", background=ACCENT, min_width="1.4rem",
1520
+ rx.box(str(number), color="white", background=ACCENT_DEEP, min_width="1.4rem",
1397
1521
  height="1.4rem", display="grid", place_items="center",
1398
1522
  border_radius="50%", font_size="0.75rem", font_weight="700"),
1399
1523
  rx.vstack(rx.text(title, font_weight="600", font_size="0.9rem"),
@@ -1516,7 +1640,7 @@ def settings_page() -> rx.Component:
1516
1640
  spacing="4", width="100%")
1517
1641
  return shell(rx.vstack(
1518
1642
  page_header(
1519
- "Settings", "Choose providers and control executor concurrency."),
1643
+ "Settings", ""),
1520
1644
  rx.box(
1521
1645
  rx.heading("Coding agent", size="4", margin_bottom="1rem"),
1522
1646
  rx.grid(
@@ -1635,10 +1759,10 @@ app = rx.App(
1635
1759
  "width": "6px",
1636
1760
  },
1637
1761
  ".workflow-route-node--forward .react-flow__handle": {
1638
- "background": "#167d5a",
1762
+ "background": "#2f6fd6",
1639
1763
  },
1640
1764
  ".workflow-route-node--return .react-flow__handle": {
1641
- "background": "#d97706",
1765
+ "background": "#e26128",
1642
1766
  },
1643
1767
  },
1644
1768
  stylesheets=[
@@ -1652,15 +1776,20 @@ app = rx.App(
1652
1776
  api_transformer=api_app,
1653
1777
  )
1654
1778
  app.add_page(dashboard_page, route="/", title="Dashboard | Codee",
1655
- on_load=AdminState.poll_dashboard)
1779
+ on_load=[AdminState.load_coding_agent, AdminState.poll_dashboard])
1656
1780
  app.add_page(skills_page, route="/skills", title="Skills | Codee",
1657
- on_load=[AdminState.load_skills, AdminState.load_agent_models])
1781
+ on_load=[AdminState.load_coding_agent, AdminState.load_skills,
1782
+ AdminState.load_agent_models])
1658
1783
  app.add_page(workflow_page, route="/workflow", title="Workflow | Codee",
1659
- on_load=AdminState.load_workflow)
1784
+ on_load=[AdminState.load_coding_agent, AdminState.load_workflow])
1660
1785
  app.add_page(memory_page, route="/memory", title="Memory | Codee",
1661
1786
  on_load=AdminState.load_memories)
1787
+ app.add_page(repositories_page, route="/repositories",
1788
+ title="Repositories | Codee",
1789
+ on_load=[AdminState.load_coding_agent,
1790
+ AdminState.load_repositories])
1662
1791
  app.add_page(runs_page, route="/runs", title="Runs | Codee",
1663
- on_load=AdminState.load_runs)
1792
+ on_load=[AdminState.load_coding_agent, AdminState.load_runs])
1664
1793
  app.add_page(sessions_page, route="/sessions",
1665
1794
  title="Sessions | Codee", on_load=AdminState.load_settings)
1666
1795
  app.add_page(settings_page, route="/settings",
@@ -22,8 +22,8 @@ config = rx.Config(
22
22
  plugins=[
23
23
  rx.plugins.RadixThemesPlugin(
24
24
  theme=rx.theme(
25
- accent_color="green",
26
- gray_color="sage",
25
+ accent_color="indigo",
26
+ gray_color="slate",
27
27
  radius="small",
28
28
  appearance="inherit",
29
29
  )
@@ -1,4 +1,5 @@
1
1
  """Framework-independent operations for the Codee admin UI."""
2
+ import hashlib
2
3
  import json
3
4
  import re
4
5
  import os
@@ -56,6 +57,20 @@ MANAGED = {
56
57
  "x-codee-aws-sqs-queue",
57
58
  }
58
59
  AGENTS_FILE = "AGENTS.md"
60
+ # Layout the coding agents are told to expect (see the AGENTS.md template):
61
+ # `repositories/<repo>/.bare` holds the bare clone, `repositories/<repo>/.git`
62
+ # points git at it, and every branch is checked out as its own worktree
63
+ # directory beside them.
64
+ REPOSITORIES_DIR = "repositories"
65
+ BARE_DIR = ".bare"
66
+ # Cloning happens on a worker thread with nobody to answer a prompt, so git is
67
+ # told to fail instead of blocking on an unknown host key or missing credentials.
68
+ GIT_NON_INTERACTIVE_ENV = {
69
+ "GIT_TERMINAL_PROMPT": "0",
70
+ "GIT_SSH_COMMAND": "ssh -o BatchMode=yes",
71
+ }
72
+ GIT_TIMEOUT = 60
73
+ GIT_NETWORK_TIMEOUT = 900
59
74
  SKILL_TYPES = [
60
75
  "knowledge",
61
76
  "slash command",
@@ -74,6 +89,9 @@ _CODING_AGENTS: dict[CodingAgent, type[AbstractCodingAgent]] = {
74
89
  }
75
90
  WORKFLOW_NODE_SPACING = 440
76
91
  WORKFLOW_NODE_CENTER_OFFSET = 110
92
+ # Inferring the workflow costs a coding-agent run, so the graph is kept in the
93
+ # data directory and reused by later admin processes.
94
+ WORKFLOW_CACHE_FILE = "workflow.json"
77
95
 
78
96
  # Port the admin UI listens on unless ``codee-admin --port`` says otherwise.
79
97
  # The OAuth redirect URI is built from it, and Entra ID matches redirect URIs
@@ -111,6 +129,22 @@ def slugify(value: str) -> str:
111
129
  return re.sub(r"[^a-z0-9-]+", "-", value.strip().lower()).strip("-")
112
130
 
113
131
 
132
+ def repository_name(url: str) -> str:
133
+ """Directory a clone URL lands in: its last path segment, without `.git`.
134
+
135
+ Handles the scp-style SSH form (`git@host:org/repo.git`) too, which has no
136
+ scheme for ``urlparse`` to read.
137
+ """
138
+ text = url.strip().rstrip("/")
139
+ if not text:
140
+ return ""
141
+ path = urlparse(text).path if "://" in text else text.rsplit(":", 1)[-1]
142
+ name = path.rstrip("/").rsplit("/", 1)[-1]
143
+ if name.endswith(".git"):
144
+ name = name[: -len(".git")]
145
+ return re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-.")
146
+
147
+
114
148
  def parse_skill(text: str) -> tuple[dict[str, Any], str]:
115
149
  match = FM_RE.match(text)
116
150
  if not match:
@@ -199,6 +233,18 @@ def _transition_values(value: Any) -> list[dict[str, str]]:
199
233
  return transitions
200
234
 
201
235
 
236
+ def _is_workflow(value: Any) -> bool:
237
+ """Whether a stored graph still has the shape the workflow page reads."""
238
+ if not isinstance(value, dict):
239
+ return False
240
+ return all(
241
+ isinstance(value.get(issue_type), dict)
242
+ and all(isinstance(value[issue_type].get(key), list)
243
+ for key in ("nodes", "edges", "warnings"))
244
+ for issue_type in ISSUE_TYPES
245
+ )
246
+
247
+
202
248
  def _remove_redundant_skill_transitions(
203
249
  transitions: list[dict[str, str]],
204
250
  ) -> list[dict[str, str]]:
@@ -240,6 +286,7 @@ class AdminService:
240
286
  self.agents_file = self.root / AGENTS_FILE
241
287
  self.memory_dir = memory_dir(self.root)
242
288
  self.memory_index = self.memory_dir / "MEMORY.md"
289
+ self.repositories_dir = self.root / REPOSITORIES_DIR
243
290
  self.data_dir = data_dir(self.root)
244
291
  self.session_viewer = os.environ.get("CODEE_SESSION_VIEWER_URL", "")
245
292
  self.skills_dir.mkdir(parents=True, exist_ok=True)
@@ -247,7 +294,8 @@ class AdminService:
247
294
  self.data_dir.mkdir(parents=True, exist_ok=True)
248
295
  self.context = CodeeMainContext(data_dir=self.data_dir)
249
296
  self.context.settings = load_settings(self.data_dir)
250
- self._workflow_cache: dict[str, Any] | None = None
297
+ # Cached graph plus the skill fingerprint it was generated from.
298
+ self._workflow_cache: tuple[str, dict[str, Any]] | None = None
251
299
  self._workflow_lock = threading.Lock()
252
300
  # Asking an agent for its catalog can mean spawning its CLI, so the
253
301
  # answer is cached per agent for the life of the process.
@@ -343,16 +391,73 @@ class AdminService:
343
391
  return ""
344
392
 
345
393
  def generate_workflow(self, force: bool = False) -> dict[str, Any]:
346
- """Return cached workflows by issue type, regenerating when requested."""
394
+ """Return cached workflows by issue type, regenerating when requested.
395
+
396
+ The cache outlives the admin process: it is stored in the data
397
+ directory under the fingerprint of the skill documents the graph was
398
+ inferred from, so a restart reuses it while an edited skill still
399
+ regenerates it.
400
+ """
347
401
  workflow_lock = getattr(self, "_workflow_lock", None)
348
402
  if workflow_lock is None:
349
403
  workflow_lock = self._workflow_lock = threading.Lock()
350
404
  with workflow_lock:
351
- workflow_cache = getattr(self, "_workflow_cache", None)
352
- if not force and workflow_cache is not None:
353
- return workflow_cache
354
- self._workflow_cache = self._generate_workflow()
355
- return self._workflow_cache
405
+ fingerprint = self._workflow_fingerprint()
406
+ if not force:
407
+ cached = self._cached_workflow(fingerprint)
408
+ if cached is not None:
409
+ return cached
410
+ workflow = self._generate_workflow()
411
+ self._workflow_cache = (fingerprint, workflow)
412
+ self._store_workflow(fingerprint, workflow)
413
+ return workflow
414
+
415
+ def _workflow_fingerprint(self) -> str:
416
+ """Digest the skill documents the workflow graph is inferred from."""
417
+ digest = hashlib.sha256()
418
+ for skill in find_issue_triggered_skills(self.skills_dir):
419
+ digest.update(skill.slug.encode())
420
+ digest.update(b"\0")
421
+ digest.update(skill.path.read_bytes())
422
+ digest.update(b"\0")
423
+ return digest.hexdigest()
424
+
425
+ def _workflow_cache_path(self) -> Path:
426
+ return self.data_dir / WORKFLOW_CACHE_FILE
427
+
428
+ def _cached_workflow(self, fingerprint: str) -> dict[str, Any] | None:
429
+ """This process's graph, else the one an earlier process stored."""
430
+ cached = getattr(self, "_workflow_cache", None)
431
+ if cached is not None and cached[0] == fingerprint:
432
+ return cached[1]
433
+ try:
434
+ stored = json.loads(self._workflow_cache_path().read_text())
435
+ except FileNotFoundError:
436
+ return None
437
+ except (OSError, ValueError) as error:
438
+ print(f"[admin] Ignoring unreadable workflow cache: {error}")
439
+ return None
440
+ if not isinstance(stored, dict) or stored.get("fingerprint") != fingerprint:
441
+ return None
442
+ workflow = stored.get("workflow")
443
+ if not _is_workflow(workflow):
444
+ return None
445
+ self._workflow_cache = (fingerprint, workflow)
446
+ return workflow
447
+
448
+ def _store_workflow(self, fingerprint: str, workflow: dict[str, Any]) -> None:
449
+ """Persist the graph for the next admin process; failing is not fatal."""
450
+ path = self._workflow_cache_path()
451
+ pending = path.with_suffix(".tmp")
452
+ try:
453
+ path.parent.mkdir(parents=True, exist_ok=True)
454
+ pending.write_text(json.dumps(
455
+ {"fingerprint": fingerprint, "workflow": workflow}))
456
+ # Replace in one step so a crash mid-write cannot leave a
457
+ # half-written cache behind.
458
+ pending.replace(path)
459
+ except OSError as error:
460
+ print(f"[admin] Failed to store workflow cache: {error}")
356
461
 
357
462
  def _generate_workflow(self) -> dict[str, Any]:
358
463
  """Infer separate status graphs for story and task skills."""
@@ -873,6 +978,118 @@ class AdminService:
873
978
  return True, True, f"Deleted {filename}"
874
979
  return True, False, f"Deleted {filename} locally, but Git push failed: {output}"
875
980
 
981
+ # --- Repositories -------------------------------------------------------
982
+
983
+ def _run_git(
984
+ self,
985
+ arguments: list[str],
986
+ cwd: Path,
987
+ timeout: int = GIT_TIMEOUT,
988
+ ) -> subprocess.CompletedProcess[str]:
989
+ return subprocess.run(
990
+ ["git", *arguments],
991
+ cwd=str(cwd),
992
+ capture_output=True,
993
+ text=True,
994
+ timeout=timeout,
995
+ env={**os.environ, **GIT_NON_INTERACTIVE_ENV},
996
+ )
997
+
998
+ def _git_output(self, arguments: list[str], cwd: Path) -> str:
999
+ """One-line git output, or '' when the command fails. Never raises."""
1000
+ try:
1001
+ result = self._run_git(arguments, cwd)
1002
+ except (OSError, subprocess.SubprocessError):
1003
+ return ""
1004
+ return result.stdout.strip() if result.returncode == 0 else ""
1005
+
1006
+ def list_repositories(self) -> list[dict[str, Any]]:
1007
+ """The repositories under `repositories/`, in name order.
1008
+
1009
+ Only directories carrying a bare clone are listed: `repositories/` is
1010
+ the agents' working area, so anything else in it is scratch.
1011
+ """
1012
+ if not self.repositories_dir.is_dir():
1013
+ return []
1014
+ repositories = []
1015
+ for directory in sorted(self.repositories_dir.iterdir()):
1016
+ if not (directory / BARE_DIR).is_dir():
1017
+ continue
1018
+ repositories.append({
1019
+ "name": directory.name,
1020
+ "url": self._git_output(
1021
+ ["remote", "get-url", "origin"], directory),
1022
+ "default_branch": self._git_output(
1023
+ ["symbolic-ref", "--short", "HEAD"], directory),
1024
+ })
1025
+ return repositories
1026
+
1027
+ def add_repository(self, url: str) -> tuple[bool, str, str]:
1028
+ """Clone `url` into the bare-plus-worktree layout the agents expect.
1029
+
1030
+ Leaves behind `repositories/<name>/.bare`, a `.git` file pointing at it,
1031
+ and a worktree for whichever branch the remote calls its default. A
1032
+ failure anywhere removes the half-built directory so a retry is clean.
1033
+ """
1034
+ url = url.strip()
1035
+ if not url:
1036
+ return False, "Enter a repository URL", ""
1037
+ name = repository_name(url)
1038
+ if not name:
1039
+ return False, f"Could not work out a folder name from {url}", ""
1040
+ directory = self.repositories_dir / name
1041
+ if directory.exists():
1042
+ return False, f"{REPOSITORIES_DIR}/{name} already exists", name
1043
+
1044
+ directory.mkdir(parents=True)
1045
+ try:
1046
+ self._clone_repository(url, directory)
1047
+ branch = self._git_output(
1048
+ ["symbolic-ref", "--short", "HEAD"], directory)
1049
+ if not branch:
1050
+ raise RuntimeError(
1051
+ "Cloned, but the remote names no default branch")
1052
+ self._check(
1053
+ self._run_git(["worktree", "add", branch], directory),
1054
+ f"Could not create the {branch} worktree",
1055
+ )
1056
+ except Exception as error:
1057
+ shutil.rmtree(directory, ignore_errors=True)
1058
+ return False, str(error), name
1059
+ return True, f"Cloned {name} and checked out {branch}", name
1060
+
1061
+ def _clone_repository(self, url: str, directory: Path) -> None:
1062
+ """Bare clone plus the wiring that makes the directory usable as a repo."""
1063
+ self._check(
1064
+ self._run_git(["clone", "--bare", url, BARE_DIR], directory,
1065
+ timeout=GIT_NETWORK_TIMEOUT),
1066
+ f"Could not clone {url}",
1067
+ )
1068
+ # Makes git treat the repository directory itself as the repo, so the
1069
+ # worktree commands in AGENTS.md run from there without pointing at
1070
+ # `.bare` every time.
1071
+ (directory / ".git").write_text(f"gitdir: ./{BARE_DIR}\n")
1072
+ # A bare clone fetches straight into `refs/heads/*` and configures no
1073
+ # remote-tracking refspec, so without this `origin/<branch>` never
1074
+ # exists and later worktrees have nothing to branch off.
1075
+ self._check(
1076
+ self._run_git(["config", "remote.origin.fetch",
1077
+ "+refs/heads/*:refs/remotes/origin/*"], directory),
1078
+ "Could not configure the origin refspec",
1079
+ )
1080
+ self._check(
1081
+ self._run_git(["fetch", "origin"], directory,
1082
+ timeout=GIT_NETWORK_TIMEOUT),
1083
+ "Could not fetch from origin",
1084
+ )
1085
+
1086
+ @staticmethod
1087
+ def _check(result: subprocess.CompletedProcess[str], message: str) -> None:
1088
+ if result.returncode != 0:
1089
+ detail = (result.stderr.strip() or result.stdout.strip()
1090
+ or f"git exited with {result.returncode}")
1091
+ raise RuntimeError(f"{message}: {detail}")
1092
+
876
1093
  def dashboard(self) -> dict[str, Any]:
877
1094
  return {
878
1095
  "active": [
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import subprocess
2
3
  import tempfile
3
4
  import threading
4
5
  import unittest
@@ -10,11 +11,31 @@ from codee_agent_abstract.provider import AgentModel
10
11
  from codee_agent_claude_code.provider import ClaudeCodeAgent
11
12
  from codee_agent_github_copilot.provider import GitHubCopilotAgent
12
13
 
13
- from codee.admin_service import AdminService, azure_oauth, parse_skill
14
+ from codee.admin_service import (
15
+ AdminService, azure_oauth, parse_skill, repository_name)
14
16
  from codee_main_context.context import (
15
17
  CodeeMainContext, CodingAgent, Settings, TasksProvider, save_settings)
16
18
 
17
19
 
20
+ def _empty_workflow() -> dict:
21
+ return {issue_type: {"nodes": [], "edges": [], "warnings": []}
22
+ for issue_type in ("story", "task")}
23
+
24
+
25
+ def _write_issue_skill(root: Path) -> Path:
26
+ """Create one issue-trigger skill, the input the workflow cache is keyed on."""
27
+ skills_dir = root / ".claude" / "skills"
28
+ skill_dir = skills_dir / "develop"
29
+ skill_dir.mkdir(parents=True)
30
+ (skill_dir / "SKILL.md").write_text(
31
+ "---\nname: develop\ndisable-model-invocation: true\n"
32
+ "x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
33
+ "x-codee-issue-type: story\n---\n"
34
+ "After implementation, move the issue to Review.\n"
35
+ )
36
+ return skills_dir
37
+
38
+
18
39
  class AdminServiceIssueTriggerTest(unittest.TestCase):
19
40
  def test_list_skills_includes_issue_status(self) -> None:
20
41
  with tempfile.TemporaryDirectory() as temporary_directory:
@@ -227,6 +248,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
227
248
  service = AdminService.__new__(AdminService)
228
249
  service.root = root
229
250
  service.skills_dir = skills_dir
251
+ service.data_dir = root / ".codee"
230
252
  service.context = Mock(settings=Settings(
231
253
  coding_agent=CodingAgent.CLAUDE_CODE))
232
254
 
@@ -315,6 +337,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
315
337
  service = AdminService.__new__(AdminService)
316
338
  service.root = root
317
339
  service.skills_dir = skills_dir
340
+ service.data_dir = root / ".codee"
318
341
  service.context = Mock(
319
342
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
320
343
 
@@ -356,6 +379,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
356
379
  service = AdminService.__new__(AdminService)
357
380
  service.root = root
358
381
  service.skills_dir = skills_dir
382
+ service.data_dir = root / ".codee"
359
383
  service.context = Mock(
360
384
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
361
385
 
@@ -406,6 +430,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
406
430
  service = AdminService.__new__(AdminService)
407
431
  service.root = root
408
432
  service.skills_dir = skills_dir
433
+ service.data_dir = root / ".codee"
409
434
  service.context = Mock(
410
435
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
411
436
 
@@ -462,6 +487,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
462
487
  service = AdminService.__new__(AdminService)
463
488
  service.root = root
464
489
  service.skills_dir = skills_dir
490
+ service.data_dir = root / ".codee"
465
491
  service.context = Mock(
466
492
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
467
493
 
@@ -501,6 +527,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
501
527
  service = AdminService.__new__(AdminService)
502
528
  service.root = root
503
529
  service.skills_dir = skills_dir
530
+ service.data_dir = root / ".codee"
504
531
  service.context = Mock(
505
532
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
506
533
 
@@ -518,8 +545,10 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
518
545
 
519
546
  def test_generate_workflow_returns_empty_without_issue_skills(self) -> None:
520
547
  with tempfile.TemporaryDirectory() as temporary_directory:
548
+ root = Path(temporary_directory)
521
549
  service = AdminService.__new__(AdminService)
522
- service.skills_dir = Path(temporary_directory)
550
+ service.skills_dir = root
551
+ service.data_dir = root / ".codee"
523
552
 
524
553
  self.assertEqual(service.generate_workflow(), {
525
554
  "story": {"nodes": [], "edges": [], "warnings": []},
@@ -527,15 +556,92 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
527
556
  })
528
557
 
529
558
  def test_generate_workflow_is_cached_until_forced(self) -> None:
530
- service = AdminService.__new__(AdminService)
531
- generated = {"nodes": [], "edges": [], "warnings": []}
559
+ with tempfile.TemporaryDirectory() as temporary_directory:
560
+ root = Path(temporary_directory)
561
+ service = AdminService.__new__(AdminService)
562
+ service.skills_dir = root / ".claude" / "skills"
563
+ service.data_dir = root / ".codee"
564
+ generated = _empty_workflow()
565
+
566
+ with patch.object(service, "_generate_workflow",
567
+ return_value=generated) as generate:
568
+ self.assertIs(service.generate_workflow(), generated)
569
+ self.assertIs(service.generate_workflow(), generated)
570
+ self.assertIs(service.generate_workflow(force=True), generated)
571
+
572
+ self.assertEqual(generate.call_count, 2)
573
+
574
+ def test_generate_workflow_reuses_graph_stored_by_an_earlier_process(self) -> None:
575
+ with tempfile.TemporaryDirectory() as temporary_directory:
576
+ root = Path(temporary_directory)
577
+ skills_dir = _write_issue_skill(root)
578
+ generated = _empty_workflow()
579
+ generated["story"]["warnings"] = ["stored"]
580
+
581
+ first = AdminService.__new__(AdminService)
582
+ first.skills_dir = skills_dir
583
+ first.data_dir = root / ".codee"
584
+ with patch.object(first, "_generate_workflow", return_value=generated):
585
+ first.generate_workflow()
586
+
587
+ # A restart starts from an empty in-memory cache, so only the file
588
+ # written above can spare it another coding-agent run.
589
+ restarted = AdminService.__new__(AdminService)
590
+ restarted.skills_dir = skills_dir
591
+ restarted.data_dir = root / ".codee"
592
+ with patch.object(restarted, "_generate_workflow") as generate:
593
+ workflow = restarted.generate_workflow()
594
+
595
+ generate.assert_not_called()
596
+ self.assertEqual(workflow["story"]["warnings"], ["stored"])
597
+
598
+ def test_generate_workflow_ignores_stored_graph_after_a_skill_changes(self) -> None:
599
+ with tempfile.TemporaryDirectory() as temporary_directory:
600
+ root = Path(temporary_directory)
601
+ skills_dir = _write_issue_skill(root)
602
+
603
+ first = AdminService.__new__(AdminService)
604
+ first.skills_dir = skills_dir
605
+ first.data_dir = root / ".codee"
606
+ with patch.object(first, "_generate_workflow",
607
+ return_value=_empty_workflow()):
608
+ first.generate_workflow()
609
+
610
+ (skills_dir / "develop" / "SKILL.md").write_text(
611
+ "---\nname: develop\ndisable-model-invocation: true\n"
612
+ "x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
613
+ "x-codee-issue-type: story\n---\n"
614
+ "After implementation, move the issue to Done.\n"
615
+ )
616
+ regenerated = _empty_workflow()
617
+ regenerated["story"]["warnings"] = ["regenerated"]
618
+ restarted = AdminService.__new__(AdminService)
619
+ restarted.skills_dir = skills_dir
620
+ restarted.data_dir = root / ".codee"
621
+ with patch.object(restarted, "_generate_workflow",
622
+ return_value=regenerated) as generate:
623
+ workflow = restarted.generate_workflow()
624
+
625
+ generate.assert_called_once_with()
626
+ self.assertEqual(workflow["story"]["warnings"], ["regenerated"])
627
+
628
+ def test_generate_workflow_ignores_a_corrupt_stored_graph(self) -> None:
629
+ with tempfile.TemporaryDirectory() as temporary_directory:
630
+ root = Path(temporary_directory)
631
+ skills_dir = _write_issue_skill(root)
632
+ data_dir = root / ".codee"
633
+ data_dir.mkdir()
634
+ (data_dir / "workflow.json").write_text("{ not json")
532
635
 
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)
636
+ service = AdminService.__new__(AdminService)
637
+ service.skills_dir = skills_dir
638
+ service.data_dir = data_dir
639
+ generated = _empty_workflow()
640
+ with patch.object(service, "_generate_workflow",
641
+ return_value=generated) as generate:
642
+ self.assertIs(service.generate_workflow(), generated)
537
643
 
538
- self.assertEqual(generate.call_count, 2)
644
+ generate.assert_called_once_with()
539
645
 
540
646
 
541
647
  class AdminServiceSkillModelTest(unittest.TestCase):
@@ -776,6 +882,120 @@ class AdminServiceAgentsFileTest(unittest.TestCase):
776
882
  self.assertIn(str(root / "AGENTS.md"), with_agents)
777
883
 
778
884
 
885
+ class AdminServiceRepositoriesTest(unittest.TestCase):
886
+ """Clones run for real against a local source repo: no network needed."""
887
+
888
+ def _service(self, root: Path) -> AdminService:
889
+ service = AdminService.__new__(AdminService)
890
+ service.root = root
891
+ service.repositories_dir = root / "repositories"
892
+ return service
893
+
894
+ def _source_repository(self, path: Path, branch: str) -> Path:
895
+ """A one-commit repository whose default branch is ``branch``."""
896
+ path.mkdir(parents=True)
897
+ self._git(path, "init", "-b", branch)
898
+ (path / "README.md").write_text("source\n")
899
+ self._git(path, "add", "README.md")
900
+ self._git(path, "-c", "user.email=codee@example.com",
901
+ "-c", "user.name=Codee", "-c", "commit.gpgsign=false",
902
+ "commit", "-m", "initial")
903
+ return path
904
+
905
+ def _git(self, cwd: Path, *arguments: str) -> str:
906
+ result = subprocess.run(["git", "-C", str(cwd), *arguments],
907
+ capture_output=True, text=True, check=True)
908
+ return result.stdout.strip()
909
+
910
+ def test_name_is_taken_from_every_url_form(self) -> None:
911
+ self.assertEqual(repository_name("git@github.com:org/codee.git"), "codee")
912
+ self.assertEqual(repository_name("ssh://git@github.com/org/codee.git"),
913
+ "codee")
914
+ self.assertEqual(repository_name("https://github.com/org/codee/"), "codee")
915
+ self.assertEqual(repository_name(" "), "")
916
+
917
+ def test_add_builds_the_bare_and_worktree_layout(self) -> None:
918
+ for branch in ("main", "master"):
919
+ with self.subTest(branch=branch), \
920
+ tempfile.TemporaryDirectory() as temporary_directory:
921
+ root = Path(temporary_directory)
922
+ source = self._source_repository(root / "source", branch)
923
+ service = self._service(root)
924
+
925
+ added, message, name = service.add_repository(str(source))
926
+
927
+ repository = root / "repositories" / "source"
928
+ self.assertTrue(added, message)
929
+ self.assertEqual(name, "source")
930
+ self.assertIn(branch, message)
931
+ self.assertTrue((repository / ".bare").is_dir())
932
+ self.assertEqual((repository / ".git").read_text(),
933
+ "gitdir: ./.bare\n")
934
+ # The branch is checked out beside `.bare`, as its own worktree.
935
+ self.assertTrue((repository / branch / "README.md").is_file())
936
+ self.assertEqual(
937
+ self._git(repository / branch, "rev-parse",
938
+ "--abbrev-ref", "HEAD"),
939
+ branch)
940
+
941
+ def test_add_fetches_remote_tracking_refs_for_later_worktrees(self) -> None:
942
+ with tempfile.TemporaryDirectory() as temporary_directory:
943
+ root = Path(temporary_directory)
944
+ source = self._source_repository(root / "source", "main")
945
+ service = self._service(root)
946
+
947
+ service.add_repository(str(source))
948
+
949
+ repository = root / "repositories" / "source"
950
+ self.assertTrue(self._git(repository, "rev-parse", "origin/main"))
951
+
952
+ def test_add_refuses_a_repository_that_is_already_there(self) -> None:
953
+ with tempfile.TemporaryDirectory() as temporary_directory:
954
+ root = Path(temporary_directory)
955
+ (root / "repositories" / "source").mkdir(parents=True)
956
+ service = self._service(root)
957
+
958
+ added, message, _ = service.add_repository(
959
+ "git@github.com:org/source.git")
960
+
961
+ self.assertFalse(added)
962
+ self.assertIn("already exists", message)
963
+
964
+ def test_a_failed_clone_leaves_no_directory_behind(self) -> None:
965
+ with tempfile.TemporaryDirectory() as temporary_directory:
966
+ root = Path(temporary_directory)
967
+ service = self._service(root)
968
+
969
+ added, message, _ = service.add_repository(
970
+ str(root / "missing-repository.git"))
971
+
972
+ self.assertFalse(added)
973
+ self.assertIn("Could not clone", message)
974
+ self.assertFalse((root / "repositories" / "missing-repository")
975
+ .exists())
976
+
977
+ def test_list_reports_the_clone_and_skips_everything_else(self) -> None:
978
+ with tempfile.TemporaryDirectory() as temporary_directory:
979
+ root = Path(temporary_directory)
980
+ source = self._source_repository(root / "source", "main")
981
+ service = self._service(root)
982
+ service.add_repository(str(source))
983
+ (root / "repositories" / "scratch").mkdir()
984
+
985
+ repositories = service.list_repositories()
986
+
987
+ self.assertEqual([repository["name"] for repository in repositories],
988
+ ["source"])
989
+ self.assertEqual(repositories[0]["url"], str(source))
990
+ self.assertEqual(repositories[0]["default_branch"], "main")
991
+
992
+ def test_list_is_empty_before_anything_is_cloned(self) -> None:
993
+ with tempfile.TemporaryDirectory() as temporary_directory:
994
+ service = self._service(Path(temporary_directory))
995
+
996
+ self.assertEqual(service.list_repositories(), [])
997
+
998
+
779
999
  class AzureDevOpsOAuthTest(unittest.TestCase):
780
1000
  """The admin side of the flow: build the URL, then handle the callback."""
781
1001
 
File without changes
File without changes