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.
- {codee_agent-0.3.0 → codee_agent-0.4.0}/PKG-INFO +1 -1
- {codee_agent-0.3.0 → codee_agent-0.4.0}/pyproject.toml +1 -1
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/admin.py +125 -9
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/admin_service.py +143 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_admin_service.py +117 -1
- {codee_agent-0.3.0 → codee_agent-0.4.0}/LICENSE +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/README.md +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/.gitignore +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/__init__.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/admin_api.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/admin_cli.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/executor.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/init_cli.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/__init__.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/cron_describe.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/runs_db.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/test_runs_db.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/test_trigger_cron_skills.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/test_trigger_issue_skills.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/trigger_cron_skills.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/trigger_email_skills.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/lib/trigger_issue_skills.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/mail_server.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/start_cli.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/AGENTS.md +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/CLAUDE.md +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_admin_api.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_admin_cli.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_executor.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_init_cli.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_memory_index.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/test_start_cli.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/workflow_graph.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_admin/__init__.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_admin/codee_admin.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_abstract/__init__.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_abstract/provider.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_claude_code/__init__.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_claude_code/provider.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_github_copilot/__init__.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_github_copilot/provider.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_agent_github_copilot/test.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_database/__init__.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_database/database.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_database/oauth_tokens.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_main_context/__init__.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_main_context/context.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_main_context/logging.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_main_context/test_logging.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_abstract/__init__.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_abstract/provider.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/__init__.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/oauth.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/provider.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/test.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_jira/__init__.py +0 -0
- {codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee_tasks_jira/provider.py +0 -0
- {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
|
+
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
|
|
@@ -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.
|
|
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",
|
|
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,
|
|
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", "
|
|
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", "
|
|
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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md
RENAMED
|
File without changes
|
{codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md
RENAMED
|
File without changes
|
{codee_agent-0.3.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|