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.
- {codee_agent-0.2.0 → codee_agent-0.4.0}/PKG-INFO +1 -1
- {codee_agent-0.2.0 → codee_agent-0.4.0}/pyproject.toml +1 -1
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/admin.py +160 -31
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/admin_cli.py +2 -2
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/admin_service.py +224 -7
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_admin_service.py +229 -9
- {codee_agent-0.2.0 → codee_agent-0.4.0}/LICENSE +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/README.md +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/.gitignore +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/__init__.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/admin_api.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/executor.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/init_cli.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/__init__.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/cron_describe.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/runs_db.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/test_runs_db.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/test_trigger_cron_skills.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/test_trigger_issue_skills.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/trigger_cron_skills.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/trigger_email_skills.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/lib/trigger_issue_skills.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/mail_server.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/start_cli.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/AGENTS.md +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/CLAUDE.md +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_admin_api.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_admin_cli.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_executor.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_init_cli.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_memory_index.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/test_start_cli.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/workflow_graph.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_admin/__init__.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_admin/codee_admin.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_abstract/__init__.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_abstract/provider.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_claude_code/__init__.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_claude_code/provider.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_github_copilot/__init__.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_github_copilot/provider.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_agent_github_copilot/test.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_database/__init__.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_database/database.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_database/oauth_tokens.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_main_context/__init__.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_main_context/context.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_main_context/logging.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_main_context/test_logging.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_abstract/__init__.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_abstract/provider.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/__init__.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/oauth.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/provider.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_azure_devops/test.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_jira/__init__.py +0 -0
- {codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee_tasks_jira/provider.py +0 -0
- {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.
|
|
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,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("#
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
"--codee-
|
|
713
|
-
"--codee-
|
|
714
|
-
"--codee-
|
|
715
|
-
"--codee-
|
|
716
|
-
"--codee-
|
|
717
|
-
"--codee-
|
|
718
|
-
"--codee-
|
|
719
|
-
"--codee-
|
|
720
|
-
"--codee-
|
|
721
|
-
"--codee-
|
|
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("#
|
|
784
|
+
"--codee-running-background": rx.color_mode_cond("#eff4fd", "#14203a"),
|
|
724
785
|
"--codee-running-glow": rx.color_mode_cond(
|
|
725
|
-
"rgba(
|
|
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",
|
|
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,
|
|
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="
|
|
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="
|
|
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", "
|
|
1196
|
-
rx.cond(AdminState.
|
|
1197
|
-
|
|
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="
|
|
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=
|
|
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", "
|
|
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": "#
|
|
1762
|
+
"background": "#2f6fd6",
|
|
1639
1763
|
},
|
|
1640
1764
|
".workflow-route-node--return .react-flow__handle": {
|
|
1641
|
-
"background": "#
|
|
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.
|
|
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",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
352
|
-
if not force
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
531
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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
|
|
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.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md
RENAMED
|
File without changes
|
{codee_agent-0.2.0 → codee_agent-0.4.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md
RENAMED
|
File without changes
|
{codee_agent-0.2.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
|