codee-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codee/.gitignore +2 -0
- codee/__init__.py +0 -0
- codee/admin.py +1672 -0
- codee/admin_api.py +61 -0
- codee/admin_cli.py +86 -0
- codee/admin_service.py +1005 -0
- codee/executor.py +381 -0
- codee/init_cli.py +82 -0
- codee/lib/__init__.py +0 -0
- codee/lib/cron_describe.py +33 -0
- codee/lib/runs_db.py +195 -0
- codee/lib/test_runs_db.py +199 -0
- codee/lib/test_trigger_cron_skills.py +485 -0
- codee/lib/test_trigger_issue_skills.py +93 -0
- codee/lib/trigger_aws_sqs_skills.py +224 -0
- codee/lib/trigger_cron_skills.py +364 -0
- codee/lib/trigger_email_skills.py +225 -0
- codee/lib/trigger_issue_skills.py +107 -0
- codee/mail_server.py +45 -0
- codee/start_cli.py +98 -0
- codee/templates/AGENTS.md +42 -0
- codee/templates/CLAUDE.md +1 -0
- codee/templates/skills/aws-sqs-alarm-response/SKILL.md +23 -0
- codee/templates/skills/cron-research-5xx-errors/SKILL.md +17 -0
- codee/templates/skills/story-code-reviewer/SKILL.md +29 -0
- codee/templates/skills/story-developer/SKILL.md +26 -0
- codee/templates/skills/story-planner/SKILL.md +35 -0
- codee/templates/skills/story-planner/assets/readme-template.md +43 -0
- codee/templates/skills/story-qa/SKILL.md +28 -0
- codee/templates/skills/task-developer/SKILL.md +25 -0
- codee/templates/skills/task-qa/SKILL.md +26 -0
- codee/test_admin_api.py +64 -0
- codee/test_admin_cli.py +63 -0
- codee/test_admin_service.py +897 -0
- codee/test_executor.py +190 -0
- codee/test_init_cli.py +131 -0
- codee/test_memory_index.py +31 -0
- codee/test_start_cli.py +164 -0
- codee/workflow_graph.py +83 -0
- codee_admin/__init__.py +1 -0
- codee_admin/codee_admin.py +4 -0
- codee_agent-0.1.0.dist-info/METADATA +66 -0
- codee_agent-0.1.0.dist-info/RECORD +69 -0
- codee_agent-0.1.0.dist-info/WHEEL +4 -0
- codee_agent-0.1.0.dist-info/entry_points.txt +6 -0
- codee_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- codee_agent_abstract/__init__.py +0 -0
- codee_agent_abstract/provider.py +56 -0
- codee_agent_claude_code/__init__.py +0 -0
- codee_agent_claude_code/provider.py +90 -0
- codee_agent_github_copilot/__init__.py +0 -0
- codee_agent_github_copilot/provider.py +253 -0
- codee_agent_github_copilot/test.py +176 -0
- codee_database/__init__.py +0 -0
- codee_database/database.py +13 -0
- codee_database/oauth_tokens.py +148 -0
- codee_main_context/__init__.py +0 -0
- codee_main_context/context.py +127 -0
- codee_main_context/logging.py +111 -0
- codee_main_context/test_logging.py +90 -0
- codee_tasks_abstract/__init__.py +0 -0
- codee_tasks_abstract/provider.py +58 -0
- codee_tasks_azure_devops/__init__.py +0 -0
- codee_tasks_azure_devops/oauth.py +346 -0
- codee_tasks_azure_devops/provider.py +207 -0
- codee_tasks_azure_devops/test.py +462 -0
- codee_tasks_jira/__init__.py +0 -0
- codee_tasks_jira/provider.py +162 -0
- codee_tasks_jira/test.py +75 -0
codee/test_executor.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from codee_agent_github_copilot.provider import GitHubCopilotAgent
|
|
8
|
+
from codee_main_context.context import (
|
|
9
|
+
CodingAgent, Settings, TasksProvider, save_settings)
|
|
10
|
+
from codee_tasks_azure_devops.provider import AzureDevOpsTasksProvider
|
|
11
|
+
from codee_tasks_jira.provider import JiraTasksProvider
|
|
12
|
+
|
|
13
|
+
from codee import executor
|
|
14
|
+
from codee.lib import runs_db
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RefreshConfigTest(unittest.TestCase):
|
|
18
|
+
"""The executor re-reads settings.json each poll, so edits need no restart."""
|
|
19
|
+
|
|
20
|
+
def setUp(self) -> None:
|
|
21
|
+
self._temporary = tempfile.TemporaryDirectory()
|
|
22
|
+
self.addCleanup(self._temporary.cleanup)
|
|
23
|
+
self.data_dir = Path(self._temporary.name)
|
|
24
|
+
# The Azure DevOps provider resolves its token store from the ambient
|
|
25
|
+
# data dir at construction, so point that at the temp dir too.
|
|
26
|
+
environment = patch.dict(os.environ,
|
|
27
|
+
{"CODEE_DATA_DIR": str(self.data_dir)})
|
|
28
|
+
environment.start()
|
|
29
|
+
self.addCleanup(environment.stop)
|
|
30
|
+
|
|
31
|
+
original_data_dir = executor.context.data_dir
|
|
32
|
+
original_settings = executor.context.settings
|
|
33
|
+
original_provider = executor.tasks_provider
|
|
34
|
+
original_agent = executor.coding_agent
|
|
35
|
+
|
|
36
|
+
def restore() -> None:
|
|
37
|
+
executor.context.data_dir = original_data_dir
|
|
38
|
+
executor.context.settings = original_settings
|
|
39
|
+
executor.tasks_provider = original_provider
|
|
40
|
+
executor.coding_agent = original_agent
|
|
41
|
+
|
|
42
|
+
self.addCleanup(restore)
|
|
43
|
+
|
|
44
|
+
executor.context.data_dir = self.data_dir
|
|
45
|
+
executor.context.settings = Settings()
|
|
46
|
+
executor.tasks_provider = executor._build_tasks_provider(Settings())
|
|
47
|
+
|
|
48
|
+
def _save(self, **overrides) -> Settings:
|
|
49
|
+
settings = Settings(**overrides)
|
|
50
|
+
save_settings(self.data_dir, settings)
|
|
51
|
+
return settings
|
|
52
|
+
|
|
53
|
+
def test_switching_provider_rebuilds_it(self) -> None:
|
|
54
|
+
self._save(
|
|
55
|
+
tasks_provider=TasksProvider.AZURE_DEVOPS,
|
|
56
|
+
credentials={TasksProvider.AZURE_DEVOPS.value: {
|
|
57
|
+
"organization_url": "https://dev.azure.com/acme",
|
|
58
|
+
"project": "Core",
|
|
59
|
+
"tenant_id": "tenant-1",
|
|
60
|
+
"client_id": "client-1",
|
|
61
|
+
"client_secret": "secret-1",
|
|
62
|
+
}},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
executor._refresh_config()
|
|
66
|
+
|
|
67
|
+
self.assertIsInstance(executor.tasks_provider, AzureDevOpsTasksProvider)
|
|
68
|
+
self.assertEqual(executor.context.settings.tasks_provider,
|
|
69
|
+
TasksProvider.AZURE_DEVOPS)
|
|
70
|
+
|
|
71
|
+
def test_new_credentials_reach_the_provider(self) -> None:
|
|
72
|
+
self._save(credentials={TasksProvider.JIRA.value: {
|
|
73
|
+
"base_url": "https://acme.atlassian.net",
|
|
74
|
+
"account_email": "bot@acme.test",
|
|
75
|
+
"api_token": "rotated-token",
|
|
76
|
+
"project": "NIM",
|
|
77
|
+
}})
|
|
78
|
+
|
|
79
|
+
executor._refresh_config()
|
|
80
|
+
|
|
81
|
+
provider = executor.tasks_provider
|
|
82
|
+
self.assertIsInstance(provider, JiraTasksProvider)
|
|
83
|
+
self.assertTrue(provider.is_configured())
|
|
84
|
+
self.assertIn("NIM", provider.describe())
|
|
85
|
+
|
|
86
|
+
def test_unchanged_settings_keep_the_live_provider(self) -> None:
|
|
87
|
+
self._save()
|
|
88
|
+
executor._refresh_config()
|
|
89
|
+
provider = executor.tasks_provider
|
|
90
|
+
|
|
91
|
+
executor._refresh_config()
|
|
92
|
+
|
|
93
|
+
self.assertIs(executor.tasks_provider, provider)
|
|
94
|
+
|
|
95
|
+
def test_broken_provider_settings_keep_polling_with_the_old_one(self) -> None:
|
|
96
|
+
self._save(credentials={TasksProvider.JIRA.value: {
|
|
97
|
+
"base_url": "https://acme.atlassian.net",
|
|
98
|
+
"account_email": "bot@acme.test",
|
|
99
|
+
"api_token": "token",
|
|
100
|
+
"project": "NIM",
|
|
101
|
+
}})
|
|
102
|
+
executor._refresh_config()
|
|
103
|
+
provider = executor.tasks_provider
|
|
104
|
+
|
|
105
|
+
self._save(
|
|
106
|
+
tasks_provider=TasksProvider.AZURE_DEVOPS,
|
|
107
|
+
credentials={TasksProvider.AZURE_DEVOPS.value: {"project": "Core"}},
|
|
108
|
+
)
|
|
109
|
+
with patch.dict(executor._TASKS_PROVIDERS,
|
|
110
|
+
{TasksProvider.AZURE_DEVOPS: _Exploding}):
|
|
111
|
+
executor._refresh_config()
|
|
112
|
+
|
|
113
|
+
self.assertIs(executor.tasks_provider, provider)
|
|
114
|
+
|
|
115
|
+
def test_switching_coding_agent_rebuilds_it(self) -> None:
|
|
116
|
+
self._save(coding_agent=CodingAgent.CLAUDE_CODE)
|
|
117
|
+
executor._refresh_config()
|
|
118
|
+
|
|
119
|
+
self._save(coding_agent=CodingAgent.GITHUB_COPILOT)
|
|
120
|
+
executor._refresh_config()
|
|
121
|
+
|
|
122
|
+
self.assertIsInstance(executor.coding_agent, GitHubCopilotAgent)
|
|
123
|
+
self.assertEqual(executor.context.settings.coding_agent,
|
|
124
|
+
CodingAgent.GITHUB_COPILOT)
|
|
125
|
+
|
|
126
|
+
def test_broken_coding_agent_settings_keep_polling_with_the_old_one(self) -> None:
|
|
127
|
+
self._save(coding_agent=CodingAgent.CLAUDE_CODE)
|
|
128
|
+
executor._refresh_config()
|
|
129
|
+
agent = executor.coding_agent
|
|
130
|
+
|
|
131
|
+
# An agent that can't be built must not take down the poll loop.
|
|
132
|
+
self._save(coding_agent=CodingAgent.GITHUB_COPILOT)
|
|
133
|
+
with patch.dict(executor._CODING_AGENTS,
|
|
134
|
+
{CodingAgent.GITHUB_COPILOT: _Exploding}):
|
|
135
|
+
executor._refresh_config()
|
|
136
|
+
|
|
137
|
+
self.assertIs(executor.coding_agent, agent)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class RunTaskLoggingTest(unittest.TestCase):
|
|
141
|
+
"""Issue-triggered coding runs land in the runs table like the other triggers."""
|
|
142
|
+
|
|
143
|
+
def setUp(self) -> None:
|
|
144
|
+
self._temporary = tempfile.TemporaryDirectory()
|
|
145
|
+
self.addCleanup(self._temporary.cleanup)
|
|
146
|
+
original_data_dir = executor.context.data_dir
|
|
147
|
+
self.addCleanup(
|
|
148
|
+
lambda: setattr(executor.context, "data_dir", original_data_dir))
|
|
149
|
+
executor.context.data_dir = Path(self._temporary.name)
|
|
150
|
+
|
|
151
|
+
def _runs(self) -> list[dict]:
|
|
152
|
+
return runs_db.recent_runs(main_context=executor.context)
|
|
153
|
+
|
|
154
|
+
def test_successful_run_is_recorded(self) -> None:
|
|
155
|
+
with patch.object(executor, "_run_agent", return_value="done"):
|
|
156
|
+
executor._run_task("NIM-1", "/story-developer NIM-1",
|
|
157
|
+
"sid-1", "story-developer")
|
|
158
|
+
|
|
159
|
+
run, = self._runs()
|
|
160
|
+
self.assertEqual(run["skill_name"], "story-developer")
|
|
161
|
+
self.assertEqual(run["trigger_type"], "issue")
|
|
162
|
+
self.assertEqual(run["status"], "succeeded")
|
|
163
|
+
self.assertEqual(run["session_id"], "sid-1")
|
|
164
|
+
self.assertEqual(run["message"], "/story-developer NIM-1")
|
|
165
|
+
|
|
166
|
+
def test_failed_run_is_recorded_with_the_error(self) -> None:
|
|
167
|
+
with patch.object(executor, "_run_agent", side_effect=RuntimeError("over limit")):
|
|
168
|
+
executor._run_task("NIM-2", "/story-developer NIM-2",
|
|
169
|
+
"sid-2", "story-developer")
|
|
170
|
+
|
|
171
|
+
run, = self._runs()
|
|
172
|
+
self.assertEqual(run["status"], "failed")
|
|
173
|
+
self.assertIn("over limit", run["error"])
|
|
174
|
+
|
|
175
|
+
def test_counts_include_issue_runs(self) -> None:
|
|
176
|
+
with patch.object(executor, "_run_agent", return_value="done"):
|
|
177
|
+
executor._run_task("NIM-3", "/story-developer NIM-3",
|
|
178
|
+
"sid-3", "story-developer")
|
|
179
|
+
|
|
180
|
+
counts = runs_db.counts(executor.context)
|
|
181
|
+
self.assertEqual(counts, {"total": 1, "last_24h": 1})
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class _Exploding:
|
|
185
|
+
def __init__(self, *args, **kwargs):
|
|
186
|
+
raise RuntimeError("bad credentials")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
unittest.main()
|
codee/test_init_cli.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from codee.init_cli import main
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InitCliTest(unittest.TestCase):
|
|
13
|
+
def test_copies_packaged_templates_to_current_directory(self) -> None:
|
|
14
|
+
original_directory = Path.cwd()
|
|
15
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
16
|
+
os.chdir(temporary_directory)
|
|
17
|
+
try:
|
|
18
|
+
self.assertEqual(main(), 0)
|
|
19
|
+
self.assertTrue(Path("AGENTS.md").is_file())
|
|
20
|
+
self.assertEqual(Path("CLAUDE.md").read_text(), "@AGENTS.md")
|
|
21
|
+
self.assertTrue(
|
|
22
|
+
Path(".claude/skills/story-planner/SKILL.md").is_file())
|
|
23
|
+
self.assertTrue(
|
|
24
|
+
Path(
|
|
25
|
+
".claude/skills/story-planner/assets/readme-template.md").is_file()
|
|
26
|
+
)
|
|
27
|
+
finally:
|
|
28
|
+
os.chdir(original_directory)
|
|
29
|
+
|
|
30
|
+
def test_copied_issue_skills_have_one_allowed_issue_type(self) -> None:
|
|
31
|
+
original_directory = Path.cwd()
|
|
32
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
33
|
+
os.chdir(temporary_directory)
|
|
34
|
+
try:
|
|
35
|
+
self.assertEqual(main(), 0)
|
|
36
|
+
issue_skills = 0
|
|
37
|
+
for path in Path(".claude/skills").glob("*/SKILL.md"):
|
|
38
|
+
parts = path.read_text().split("---", 2)
|
|
39
|
+
frontmatter = yaml.safe_load(parts[1]) or {}
|
|
40
|
+
if frontmatter.get("x-codee-trigger") != "issue":
|
|
41
|
+
continue
|
|
42
|
+
issue_skills += 1
|
|
43
|
+
self.assertIn(
|
|
44
|
+
frontmatter.get("x-codee-issue-type"),
|
|
45
|
+
{"story", "task"},
|
|
46
|
+
path,
|
|
47
|
+
)
|
|
48
|
+
self.assertGreater(issue_skills, 0)
|
|
49
|
+
finally:
|
|
50
|
+
os.chdir(original_directory)
|
|
51
|
+
|
|
52
|
+
def test_creates_working_directories_and_ignores_them(self) -> None:
|
|
53
|
+
original_directory = Path.cwd()
|
|
54
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
55
|
+
os.chdir(temporary_directory)
|
|
56
|
+
try:
|
|
57
|
+
self.assertEqual(main(), 0)
|
|
58
|
+
self.assertTrue(Path("repositories").is_dir())
|
|
59
|
+
self.assertTrue(Path("temp").is_dir())
|
|
60
|
+
self.assertTrue(Path("memory").is_dir())
|
|
61
|
+
# memory/ stays tracked: the admin UI commits memory edits.
|
|
62
|
+
self.assertEqual(
|
|
63
|
+
Path(".gitignore").read_text(), "/repositories\n/temp\n")
|
|
64
|
+
finally:
|
|
65
|
+
os.chdir(original_directory)
|
|
66
|
+
|
|
67
|
+
def test_appends_only_missing_gitignore_entries(self) -> None:
|
|
68
|
+
original_directory = Path.cwd()
|
|
69
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
70
|
+
os.chdir(temporary_directory)
|
|
71
|
+
try:
|
|
72
|
+
# No trailing newline, and `repositories` in an equivalent form.
|
|
73
|
+
Path(".gitignore").write_text("*.log\nrepositories/")
|
|
74
|
+
self.assertEqual(main(), 0)
|
|
75
|
+
self.assertEqual(
|
|
76
|
+
Path(".gitignore").read_text(),
|
|
77
|
+
"*.log\nrepositories/\n/temp\n",
|
|
78
|
+
)
|
|
79
|
+
finally:
|
|
80
|
+
os.chdir(original_directory)
|
|
81
|
+
|
|
82
|
+
def test_leaves_gitignore_untouched_when_already_covered(self) -> None:
|
|
83
|
+
original_directory = Path.cwd()
|
|
84
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
85
|
+
os.chdir(temporary_directory)
|
|
86
|
+
try:
|
|
87
|
+
Path(".gitignore").write_text("/repositories\n/temp\n")
|
|
88
|
+
self.assertEqual(main(), 0)
|
|
89
|
+
self.assertEqual(
|
|
90
|
+
Path(".gitignore").read_text(), "/repositories\n/temp\n")
|
|
91
|
+
finally:
|
|
92
|
+
os.chdir(original_directory)
|
|
93
|
+
|
|
94
|
+
def test_declining_prompt_leaves_existing_files_unchanged(self) -> None:
|
|
95
|
+
original_directory = Path.cwd()
|
|
96
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
97
|
+
os.chdir(temporary_directory)
|
|
98
|
+
try:
|
|
99
|
+
Path("AGENTS.md").write_text("custom")
|
|
100
|
+
with patch("builtins.input", return_value="no") as prompt:
|
|
101
|
+
self.assertEqual(main(), 1)
|
|
102
|
+
self.assertEqual(Path("AGENTS.md").read_text(), "custom")
|
|
103
|
+
self.assertFalse(Path("CLAUDE.md").exists())
|
|
104
|
+
self.assertFalse(Path("repositories").exists())
|
|
105
|
+
self.assertFalse(Path(".gitignore").exists())
|
|
106
|
+
prompt.assert_called_once()
|
|
107
|
+
finally:
|
|
108
|
+
os.chdir(original_directory)
|
|
109
|
+
|
|
110
|
+
def test_confirming_prompt_updates_templates_and_preserves_claude_settings(self) -> None:
|
|
111
|
+
original_directory = Path.cwd()
|
|
112
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
113
|
+
os.chdir(temporary_directory)
|
|
114
|
+
try:
|
|
115
|
+
Path(".claude").mkdir()
|
|
116
|
+
Path(".claude/settings.json").write_text("custom")
|
|
117
|
+
Path("AGENTS.md").write_text("old")
|
|
118
|
+
with patch("builtins.input", return_value="yes") as prompt:
|
|
119
|
+
self.assertEqual(main(), 0)
|
|
120
|
+
self.assertNotEqual(Path("AGENTS.md").read_text(), "old")
|
|
121
|
+
self.assertEqual(
|
|
122
|
+
Path(".claude/settings.json").read_text(), "custom")
|
|
123
|
+
self.assertTrue(
|
|
124
|
+
Path(".claude/skills/task-qa/SKILL.md").is_file())
|
|
125
|
+
prompt.assert_called_once()
|
|
126
|
+
finally:
|
|
127
|
+
os.chdir(original_directory)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
unittest.main()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Unit tests for the MEMORY.md line parser."""
|
|
2
|
+
from codee.admin_service import parse_index
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_wellformed_line():
|
|
6
|
+
[e] = parse_index("- [My Title](my_file.md) — short hook")
|
|
7
|
+
assert e["matched"]
|
|
8
|
+
assert e["title"] == "My Title"
|
|
9
|
+
assert e["file"] == "my_file.md"
|
|
10
|
+
assert e["hook"] == "short hook"
|
|
11
|
+
assert e["lineno"] == 0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_line_without_hook():
|
|
15
|
+
[e] = parse_index("- [No Hook](nohook.md)")
|
|
16
|
+
assert e["matched"]
|
|
17
|
+
assert e["file"] == "nohook.md"
|
|
18
|
+
assert e["hook"] == ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_non_matching_line_preserved():
|
|
22
|
+
[e] = parse_index("## Some heading, not an entry")
|
|
23
|
+
assert e["matched"] is False
|
|
24
|
+
assert e["raw"] == "## Some heading, not an entry"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
test_wellformed_line()
|
|
29
|
+
test_line_without_hook()
|
|
30
|
+
test_non_matching_line_preserved()
|
|
31
|
+
print("ok")
|
codee/test_start_cli.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import Mock, call, patch
|
|
8
|
+
|
|
9
|
+
from codee_main_context.logging import DEBUG_ENV_VAR
|
|
10
|
+
|
|
11
|
+
from codee.start_cli import _ensure_initialized, main
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StartCliTest(unittest.TestCase):
|
|
15
|
+
@patch("codee.start_cli.init_main")
|
|
16
|
+
def test_initializes_empty_current_directory(self, init_main: Mock) -> None:
|
|
17
|
+
original_directory = Path.cwd()
|
|
18
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
19
|
+
os.chdir(temporary_directory)
|
|
20
|
+
try:
|
|
21
|
+
_ensure_initialized()
|
|
22
|
+
finally:
|
|
23
|
+
os.chdir(original_directory)
|
|
24
|
+
|
|
25
|
+
init_main.assert_called_once_with()
|
|
26
|
+
|
|
27
|
+
@patch("codee.start_cli.init_main")
|
|
28
|
+
def test_skips_initialization_when_target_exists(self, init_main: Mock) -> None:
|
|
29
|
+
original_directory = Path.cwd()
|
|
30
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
31
|
+
os.chdir(temporary_directory)
|
|
32
|
+
try:
|
|
33
|
+
Path(".claude").mkdir()
|
|
34
|
+
with self.assertLogs("codee.start_cli", level="INFO") as logs:
|
|
35
|
+
_ensure_initialized()
|
|
36
|
+
finally:
|
|
37
|
+
os.chdir(original_directory)
|
|
38
|
+
|
|
39
|
+
init_main.assert_not_called()
|
|
40
|
+
self.assertEqual(
|
|
41
|
+
logs.output,
|
|
42
|
+
["INFO:codee.start_cli:Codee is already initialized; skipping codee-init"],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@patch("codee.start_cli.init_main")
|
|
46
|
+
def test_creates_working_directories_when_initialization_is_skipped(
|
|
47
|
+
self, init_main: Mock
|
|
48
|
+
) -> None:
|
|
49
|
+
original_directory = Path.cwd()
|
|
50
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
51
|
+
os.chdir(temporary_directory)
|
|
52
|
+
try:
|
|
53
|
+
Path(".claude").mkdir()
|
|
54
|
+
with self.assertLogs("codee.start_cli", level="INFO"):
|
|
55
|
+
_ensure_initialized()
|
|
56
|
+
self.assertTrue(Path("repositories").is_dir())
|
|
57
|
+
self.assertTrue(Path("temp").is_dir())
|
|
58
|
+
self.assertTrue(Path("memory").is_dir())
|
|
59
|
+
self.assertEqual(
|
|
60
|
+
Path(".gitignore").read_text(), "/repositories\n/temp\n")
|
|
61
|
+
finally:
|
|
62
|
+
os.chdir(original_directory)
|
|
63
|
+
|
|
64
|
+
init_main.assert_not_called()
|
|
65
|
+
|
|
66
|
+
@patch("codee.start_cli._ensure_initialized")
|
|
67
|
+
@patch("codee.start_cli.subprocess.Popen")
|
|
68
|
+
def test_starts_both_services_and_stops_survivor(
|
|
69
|
+
self, popen: Mock, ensure_initialized: Mock
|
|
70
|
+
) -> None:
|
|
71
|
+
executor = Mock()
|
|
72
|
+
executor.poll.return_value = 0
|
|
73
|
+
admin = Mock()
|
|
74
|
+
admin.poll.return_value = None
|
|
75
|
+
popen.side_effect = [executor, admin]
|
|
76
|
+
|
|
77
|
+
with patch.object(sys, "argv", ["codee-start", "--server.port", "8502"]):
|
|
78
|
+
self.assertEqual(main(), 0)
|
|
79
|
+
|
|
80
|
+
self.assertEqual(
|
|
81
|
+
popen.call_args_list,
|
|
82
|
+
[
|
|
83
|
+
call([sys.executable, "-m", "codee.executor"]),
|
|
84
|
+
call(
|
|
85
|
+
[
|
|
86
|
+
sys.executable,
|
|
87
|
+
"-m",
|
|
88
|
+
"codee.admin_cli",
|
|
89
|
+
"--server.port",
|
|
90
|
+
"8502",
|
|
91
|
+
]
|
|
92
|
+
),
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
executor.terminate.assert_not_called()
|
|
96
|
+
admin.terminate.assert_called_once_with()
|
|
97
|
+
admin.wait.assert_called_once_with(timeout=5)
|
|
98
|
+
ensure_initialized.assert_called_once_with()
|
|
99
|
+
|
|
100
|
+
@patch("codee.start_cli._ensure_initialized")
|
|
101
|
+
@patch("codee.start_cli.subprocess.Popen")
|
|
102
|
+
def test_debug_flag_is_exported_and_not_forwarded(
|
|
103
|
+
self, popen: Mock, ensure_initialized: Mock
|
|
104
|
+
) -> None:
|
|
105
|
+
executor = Mock()
|
|
106
|
+
executor.poll.return_value = 0
|
|
107
|
+
admin = Mock()
|
|
108
|
+
admin.poll.return_value = None
|
|
109
|
+
popen.side_effect = [executor, admin]
|
|
110
|
+
|
|
111
|
+
with patch.dict(os.environ, {}, clear=False):
|
|
112
|
+
os.environ.pop(DEBUG_ENV_VAR, None)
|
|
113
|
+
with patch.object(sys, "argv", ["codee-start", "--debug", "--port", "8502"]):
|
|
114
|
+
self.assertEqual(main(), 0)
|
|
115
|
+
# Subprocesses inherit the environment, so this is what turns debug
|
|
116
|
+
# on in the executor and the admin UI.
|
|
117
|
+
self.assertEqual(os.environ[DEBUG_ENV_VAR], "1")
|
|
118
|
+
|
|
119
|
+
self.assertEqual(
|
|
120
|
+
popen.call_args_list[1],
|
|
121
|
+
call([sys.executable, "-m", "codee.admin_cli", "--port", "8502"]),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@patch("codee.start_cli._ensure_initialized")
|
|
125
|
+
@patch("codee.start_cli.time.sleep", side_effect=KeyboardInterrupt)
|
|
126
|
+
@patch("codee.start_cli.subprocess.Popen")
|
|
127
|
+
def test_ctrl_c_stops_both_services(
|
|
128
|
+
self, popen: Mock, sleep: Mock, ensure_initialized: Mock
|
|
129
|
+
) -> None:
|
|
130
|
+
executor = Mock()
|
|
131
|
+
executor.poll.return_value = None
|
|
132
|
+
admin = Mock()
|
|
133
|
+
admin.poll.return_value = None
|
|
134
|
+
popen.side_effect = [executor, admin]
|
|
135
|
+
|
|
136
|
+
with patch.object(sys, "argv", ["codee-start"]):
|
|
137
|
+
self.assertEqual(main(), 130)
|
|
138
|
+
|
|
139
|
+
executor.terminate.assert_called_once_with()
|
|
140
|
+
admin.terminate.assert_called_once_with()
|
|
141
|
+
ensure_initialized.assert_called_once_with()
|
|
142
|
+
|
|
143
|
+
@patch("codee.start_cli._ensure_initialized")
|
|
144
|
+
@patch("codee.start_cli.subprocess.Popen")
|
|
145
|
+
def test_kills_service_that_does_not_stop(
|
|
146
|
+
self, popen: Mock, ensure_initialized: Mock
|
|
147
|
+
) -> None:
|
|
148
|
+
executor = Mock()
|
|
149
|
+
executor.poll.return_value = 1
|
|
150
|
+
admin = Mock()
|
|
151
|
+
admin.poll.return_value = None
|
|
152
|
+
admin.wait.side_effect = [subprocess.TimeoutExpired("admin", 5), 0]
|
|
153
|
+
popen.side_effect = [executor, admin]
|
|
154
|
+
|
|
155
|
+
with patch.object(sys, "argv", ["codee-start"]):
|
|
156
|
+
self.assertEqual(main(), 1)
|
|
157
|
+
|
|
158
|
+
admin.kill.assert_called_once_with()
|
|
159
|
+
self.assertEqual(admin.wait.call_args_list, [call(timeout=5), call()])
|
|
160
|
+
ensure_initialized.assert_called_once_with()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
unittest.main()
|
codee/workflow_graph.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Reflex wrappers for the React Flow workflow graph."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import reflex as rx
|
|
5
|
+
from reflex_base.components.component import Component, NoSSRComponent, field
|
|
6
|
+
from reflex_base.event import EventHandler, no_args_event_spec
|
|
7
|
+
from reflex_base.vars.base import Var
|
|
8
|
+
|
|
9
|
+
REACT_FLOW_LIBRARY = "@xyflow/react@12.9.3"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _edge_click_signature(
|
|
13
|
+
event: Var, edge: Var
|
|
14
|
+
) -> tuple[Var[list[str]], Var[float], Var[float]]:
|
|
15
|
+
"""Pass the clicked edge's skills and the viewport click position."""
|
|
16
|
+
return (
|
|
17
|
+
Var(_js_expr=f"({edge}?.data?.skills ?? [])"),
|
|
18
|
+
Var(_js_expr=f"{event}.clientX"),
|
|
19
|
+
Var(_js_expr=f"{event}.clientY"),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ReactFlow(NoSSRComponent):
|
|
24
|
+
library = REACT_FLOW_LIBRARY
|
|
25
|
+
tag = "ReactFlow"
|
|
26
|
+
|
|
27
|
+
nodes: Var[list[dict[str, Any]]] = field()
|
|
28
|
+
edges: Var[list[dict[str, Any]]] = field()
|
|
29
|
+
fit_view: Var[bool] = field()
|
|
30
|
+
fit_view_options: Var[dict[str, Any]] = field()
|
|
31
|
+
nodes_draggable: Var[bool] = field()
|
|
32
|
+
nodes_connectable: Var[bool] = field()
|
|
33
|
+
elements_selectable: Var[bool] = field()
|
|
34
|
+
min_zoom: Var[float] = field()
|
|
35
|
+
max_zoom: Var[float] = field()
|
|
36
|
+
pro_options: Var[dict[str, Any]] = field()
|
|
37
|
+
|
|
38
|
+
on_edge_click: EventHandler[_edge_click_signature] = field()
|
|
39
|
+
on_pane_click: EventHandler[no_args_event_spec] = field()
|
|
40
|
+
|
|
41
|
+
def add_imports(self) -> dict[str, str]:
|
|
42
|
+
return {"": "@xyflow/react/dist/style.css"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Background(Component):
|
|
46
|
+
library = REACT_FLOW_LIBRARY
|
|
47
|
+
tag = "Background"
|
|
48
|
+
|
|
49
|
+
gap: Var[int] = field()
|
|
50
|
+
size: Var[int] = field()
|
|
51
|
+
color: Var[str] = field()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def workflow_graph(
|
|
55
|
+
nodes: list[dict[str, Any]] | Var,
|
|
56
|
+
edges: list[dict[str, Any]] | Var,
|
|
57
|
+
on_edge_click: Any = None,
|
|
58
|
+
on_pane_click: Any = None,
|
|
59
|
+
) -> rx.Component:
|
|
60
|
+
return rx.box(
|
|
61
|
+
ReactFlow.create(
|
|
62
|
+
Background.create(gap=20, size=1, color="var(--codee-grid)"),
|
|
63
|
+
nodes=nodes,
|
|
64
|
+
edges=edges,
|
|
65
|
+
on_edge_click=on_edge_click,
|
|
66
|
+
on_pane_click=on_pane_click,
|
|
67
|
+
fit_view=True,
|
|
68
|
+
fit_view_options={"padding": 0.25},
|
|
69
|
+
nodes_draggable=False,
|
|
70
|
+
nodes_connectable=False,
|
|
71
|
+
elements_selectable=True,
|
|
72
|
+
min_zoom=0.25,
|
|
73
|
+
max_zoom=1.75,
|
|
74
|
+
pro_options={"hideAttribution": True},
|
|
75
|
+
width="100%",
|
|
76
|
+
height="100%",
|
|
77
|
+
),
|
|
78
|
+
height="560px",
|
|
79
|
+
min_height="28rem",
|
|
80
|
+
background="var(--codee-surface)",
|
|
81
|
+
border="1px solid var(--codee-border)",
|
|
82
|
+
width="100%",
|
|
83
|
+
)
|
codee_admin/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reflex entry package for the Codee admin UI."""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codee-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents.
|
|
5
|
+
Keywords: ai,agent,jira,azure-devops,claude-code,github-copilot,automation
|
|
6
|
+
Author: Denis Kibalko
|
|
7
|
+
Author-email: Denis Kibalko <denis.kibalko@nimbusweb.me>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Bug Tracking
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Requires-Dist: boto3>=1.35,<2
|
|
20
|
+
Requires-Dist: requests>=2.31,<3
|
|
21
|
+
Requires-Dist: aiosmtpd>=1.4,<2
|
|
22
|
+
Requires-Dist: pyyaml>=6.0.3,<7
|
|
23
|
+
Requires-Dist: cron-descriptor>=2.1.0,<3
|
|
24
|
+
Requires-Dist: python-dotenv>=1.2.2,<2
|
|
25
|
+
Requires-Dist: reflex>=0.9.7
|
|
26
|
+
Requires-Python: >=3.12
|
|
27
|
+
Project-URL: Homepage, https://github.com/fusebase-dev/codee
|
|
28
|
+
Project-URL: Repository, https://github.com/fusebase-dev/codee
|
|
29
|
+
Project-URL: Issues, https://github.com/fusebase-dev/codee/issues
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Codee - a virtual co-worker
|
|
33
|
+
|
|
34
|
+
The goal of this project is to provide an assistant that will integrate into your existing environment in order to help you to solve tasks.
|
|
35
|
+
|
|
36
|
+
Codee works with Jira and Azure DevOps as tasks provider, new providers are quite easy to create, PRs are welcome.
|
|
37
|
+
|
|
38
|
+
As agents it current works with Claude Code and Github Copilot.
|
|
39
|
+
|
|
40
|
+
## Run Codee
|
|
41
|
+
|
|
42
|
+
Codee is a python package, you first need to install it.
|
|
43
|
+
|
|
44
|
+
TODO: provide install instructions
|
|
45
|
+
|
|
46
|
+
You need to create a separate repo for Codee where it will store skills, memory, temp files, config, etc.
|
|
47
|
+
|
|
48
|
+
Run Codee:
|
|
49
|
+
|
|
50
|
+
`uv run codee-start`
|
|
51
|
+
|
|
52
|
+
On the first run, the command initializes Codee instructions and skills.
|
|
53
|
+
|
|
54
|
+
## Debug mode
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv run codee-start --debug # Codee debug output
|
|
58
|
+
uv run codee-start --debug-all # ...plus reflex, boto3, urllib3 and friends
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Skills
|
|
62
|
+
|
|
63
|
+
Codee uses skills to perform tasks. It is the same skills which are used in Claude Code, Codex and other agents.
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
TODO: add skill types, x-codee skills extension
|