codee-agent 0.3.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.3.0 → codee_agent-0.4.0}/PKG-INFO +1 -1
  2. {codee_agent-0.3.0 → codee_agent-0.4.0}/pyproject.toml +1 -1
  3. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/admin.py +125 -9
  4. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/admin_service.py +143 -0
  5. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_admin_service.py +117 -1
  6. {codee_agent-0.3.0 → codee_agent-0.4.0}/LICENSE +0 -0
  7. {codee_agent-0.3.0 → codee_agent-0.4.0}/README.md +0 -0
  8. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/.gitignore +0 -0
  9. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/__init__.py +0 -0
  10. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/admin_api.py +0 -0
  11. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/admin_cli.py +0 -0
  12. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/executor.py +0 -0
  13. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/init_cli.py +0 -0
  14. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/__init__.py +0 -0
  15. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/cron_describe.py +0 -0
  16. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/runs_db.py +0 -0
  17. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/test_runs_db.py +0 -0
  18. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  19. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  20. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
  21. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/trigger_cron_skills.py +0 -0
  22. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/trigger_email_skills.py +0 -0
  23. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/trigger_issue_skills.py +0 -0
  24. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/mail_server.py +0 -0
  25. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/start_cli.py +0 -0
  26. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/AGENTS.md +0 -0
  27. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/CLAUDE.md +0 -0
  28. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  29. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  30. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  31. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  32. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  33. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  34. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  35. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  36. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  37. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_admin_api.py +0 -0
  38. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_admin_cli.py +0 -0
  39. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_executor.py +0 -0
  40. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_init_cli.py +0 -0
  41. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_memory_index.py +0 -0
  42. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_start_cli.py +0 -0
  43. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/workflow_graph.py +0 -0
  44. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_admin/__init__.py +0 -0
  45. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_admin/codee_admin.py +0 -0
  46. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_abstract/__init__.py +0 -0
  47. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_abstract/provider.py +0 -0
  48. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_claude_code/__init__.py +0 -0
  49. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_claude_code/provider.py +0 -0
  50. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_github_copilot/__init__.py +0 -0
  51. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_github_copilot/provider.py +0 -0
  52. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_github_copilot/test.py +0 -0
  53. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_database/__init__.py +0 -0
  54. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_database/database.py +0 -0
  55. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_database/oauth_tokens.py +0 -0
  56. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_main_context/__init__.py +0 -0
  57. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_main_context/context.py +0 -0
  58. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_main_context/logging.py +0 -0
  59. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_main_context/test_logging.py +0 -0
  60. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_abstract/__init__.py +0 -0
  61. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_abstract/provider.py +0 -0
  62. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/__init__.py +0 -0
  63. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/oauth.py +0 -0
  64. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/provider.py +0 -0
  65. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/test.py +0 -0
  66. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_jira/__init__.py +0 -0
  67. {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_jira/provider.py +0 -0
  68. {codee_agent-0.3.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.3.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.3.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,7 +350,7 @@ class AdminState(rx.State):
335
350
  return _save_toast(*SERVICE.save_agents(self.agents_content))
336
351
 
337
352
  def load_memories(self) -> None:
338
- self.coding_agent = SERVICE.load_settings().coding_agent.value
353
+ self.load_coding_agent()
339
354
  if self.coding_agent == "github_copilot":
340
355
  self.memories = []
341
356
  self.selected_memory = ""
@@ -367,6 +382,33 @@ class AdminState(rx.State):
367
382
  self.load_memories()
368
383
  return _save_toast(deleted, pushed, message)
369
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
+
370
412
  def _refresh_dashboard(self) -> None:
371
413
  dashboard = SERVICE.dashboard()
372
414
  self.active_jobs = [
@@ -476,6 +518,14 @@ class AdminState(rx.State):
476
518
  self.edit_skill(slug)
477
519
  return rx.redirect("/skills")
478
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
+
479
529
  def load_settings(self) -> Any:
480
530
  settings = SERVICE.load_settings()
481
531
  self.tasks_provider = settings.tasks_provider.value
@@ -682,6 +732,8 @@ def shell(content: rx.Component) -> rx.Component:
682
732
  nav_link("Skills", "blocks", "/skills"),
683
733
  nav_link("Workflow", "git-branch", "/workflow"),
684
734
  nav_link("Memory", "notebook-text", "/memory"),
735
+ nav_link("Repositories", "folder-git-2",
736
+ "/repositories"),
685
737
  nav_link("Runs", "history", "/runs"),
686
738
  rx.cond(
687
739
  AdminState.coding_agent == "claude_code",
@@ -762,7 +814,8 @@ def live_dot(size: str = "0.6rem") -> rx.Component:
762
814
  return rx.box(
763
815
  rx.box(position="absolute", inset="0", border_radius="50%", background=ACCENT,
764
816
  animation="codee-ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite"),
765
- rx.box(position="absolute", inset="0", border_radius="50%", background=ACCENT),
817
+ rx.box(position="absolute", inset="0",
818
+ border_radius="50%", background=ACCENT),
766
819
  class_name="codee-live-dot",
767
820
  position="relative",
768
821
  width=size,
@@ -774,7 +827,8 @@ def live_dot(size: str = "0.6rem") -> rx.Component:
774
827
  def elapsed_pill(label: rx.Var | str) -> rx.Component:
775
828
  return rx.hstack(
776
829
  rx.icon("timer", size=14, color=ACCENT),
777
- 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"),
778
832
  spacing="2",
779
833
  align="center",
780
834
  flex_shrink="0",
@@ -1201,7 +1255,7 @@ def memory_page() -> rx.Component:
1201
1255
  rx.vstack(rx.foreach(AdminState.memories,
1202
1256
  memory_row), spacing="3", width="100%"),
1203
1257
  empty_state("notebook-text", "No memories yet."))
1204
- return shell(rx.vstack(page_header("Memory", "Review and maintain durable project context."),
1258
+ return shell(rx.vstack(page_header("Memory", "Manage agent provider's memory."),
1205
1259
  rx.cond(AdminState.coding_agent == "github_copilot",
1206
1260
  empty_state(
1207
1261
  "notebook-text",
@@ -1211,6 +1265,63 @@ def memory_page() -> rx.Component:
1211
1265
  align="start", width="100%"))
1212
1266
 
1213
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
+
1214
1325
  def run_row(run: RunRecord) -> rx.Component:
1215
1326
  return rx.box(
1216
1327
  rx.flex(
@@ -1529,7 +1640,7 @@ def settings_page() -> rx.Component:
1529
1640
  spacing="4", width="100%")
1530
1641
  return shell(rx.vstack(
1531
1642
  page_header(
1532
- "Settings", "Choose providers and control executor concurrency."),
1643
+ "Settings", ""),
1533
1644
  rx.box(
1534
1645
  rx.heading("Coding agent", size="4", margin_bottom="1rem"),
1535
1646
  rx.grid(
@@ -1665,15 +1776,20 @@ app = rx.App(
1665
1776
  api_transformer=api_app,
1666
1777
  )
1667
1778
  app.add_page(dashboard_page, route="/", title="Dashboard | Codee",
1668
- on_load=AdminState.poll_dashboard)
1779
+ on_load=[AdminState.load_coding_agent, AdminState.poll_dashboard])
1669
1780
  app.add_page(skills_page, route="/skills", title="Skills | Codee",
1670
- on_load=[AdminState.load_skills, AdminState.load_agent_models])
1781
+ on_load=[AdminState.load_coding_agent, AdminState.load_skills,
1782
+ AdminState.load_agent_models])
1671
1783
  app.add_page(workflow_page, route="/workflow", title="Workflow | Codee",
1672
- on_load=AdminState.load_workflow)
1784
+ on_load=[AdminState.load_coding_agent, AdminState.load_workflow])
1673
1785
  app.add_page(memory_page, route="/memory", title="Memory | Codee",
1674
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])
1675
1791
  app.add_page(runs_page, route="/runs", title="Runs | Codee",
1676
- on_load=AdminState.load_runs)
1792
+ on_load=[AdminState.load_coding_agent, AdminState.load_runs])
1677
1793
  app.add_page(sessions_page, route="/sessions",
1678
1794
  title="Sessions | Codee", on_load=AdminState.load_settings)
1679
1795
  app.add_page(settings_page, route="/settings",
@@ -57,6 +57,20 @@ MANAGED = {
57
57
  "x-codee-aws-sqs-queue",
58
58
  }
59
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
60
74
  SKILL_TYPES = [
61
75
  "knowledge",
62
76
  "slash command",
@@ -115,6 +129,22 @@ def slugify(value: str) -> str:
115
129
  return re.sub(r"[^a-z0-9-]+", "-", value.strip().lower()).strip("-")
116
130
 
117
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
+
118
148
  def parse_skill(text: str) -> tuple[dict[str, Any], str]:
119
149
  match = FM_RE.match(text)
120
150
  if not match:
@@ -256,6 +286,7 @@ class AdminService:
256
286
  self.agents_file = self.root / AGENTS_FILE
257
287
  self.memory_dir = memory_dir(self.root)
258
288
  self.memory_index = self.memory_dir / "MEMORY.md"
289
+ self.repositories_dir = self.root / REPOSITORIES_DIR
259
290
  self.data_dir = data_dir(self.root)
260
291
  self.session_viewer = os.environ.get("CODEE_SESSION_VIEWER_URL", "")
261
292
  self.skills_dir.mkdir(parents=True, exist_ok=True)
@@ -947,6 +978,118 @@ class AdminService:
947
978
  return True, True, f"Deleted {filename}"
948
979
  return True, False, f"Deleted {filename} locally, but Git push failed: {output}"
949
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
+
950
1093
  def dashboard(self) -> dict[str, Any]:
951
1094
  return {
952
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,7 +11,8 @@ 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
 
@@ -880,6 +882,120 @@ class AdminServiceAgentsFileTest(unittest.TestCase):
880
882
  self.assertIn(str(root / "AGENTS.md"), with_agents)
881
883
 
882
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
+
883
999
  class AzureDevOpsOAuthTest(unittest.TestCase):
884
1000
  """The admin side of the flow: build the URL, then handle the callback."""
885
1001
 
File without changes
File without changes