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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import queue
|
|
3
|
+
import subprocess
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import Mock, patch
|
|
7
|
+
|
|
8
|
+
from codee_agent_github_copilot.provider import GitHubCopilotAgent, _await_result
|
|
9
|
+
from codee_main_context.context import Settings
|
|
10
|
+
|
|
11
|
+
SESSION = "82232f47-df60-4cb3-8c3a-de12074c9205"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _event(kind: str, data: dict | None = None, **extra) -> str:
|
|
15
|
+
event = {"type": kind, **extra}
|
|
16
|
+
if data is not None:
|
|
17
|
+
event["data"] = data
|
|
18
|
+
return json.dumps(event)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _stream(*lines: str) -> str:
|
|
22
|
+
return "\n".join(lines) + "\n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _completed(stdout: str = "", stderr: str = "", returncode: int = 0):
|
|
26
|
+
return subprocess.CompletedProcess(
|
|
27
|
+
args=["copilot"], returncode=returncode, stdout=stdout, stderr=stderr)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CopilotRunTest(unittest.TestCase):
|
|
31
|
+
def setUp(self) -> None:
|
|
32
|
+
self.agent = GitHubCopilotAgent(Settings(), Path("/repo"))
|
|
33
|
+
|
|
34
|
+
def _run(self, completed, model: str = "") -> str:
|
|
35
|
+
with patch("subprocess.run", return_value=completed) as run:
|
|
36
|
+
self.captured = run
|
|
37
|
+
return self.agent.run("/do-it CORE-1", SESSION, model)
|
|
38
|
+
|
|
39
|
+
def test_returns_the_last_assistant_message(self) -> None:
|
|
40
|
+
stdout = _stream(
|
|
41
|
+
_event("assistant.message", {"content": "Looking at it", "toolRequests": [{}]}),
|
|
42
|
+
_event("tool.execution_complete", {}),
|
|
43
|
+
_event("assistant.message", {"content": "Done, PR is up.\n"}),
|
|
44
|
+
_event("result", exitCode=0, sessionId=SESSION),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self.assertEqual(self._run(_completed(stdout)), "Done, PR is up.")
|
|
48
|
+
|
|
49
|
+
def test_ignores_trailing_messages_that_only_call_tools(self) -> None:
|
|
50
|
+
stdout = _stream(
|
|
51
|
+
_event("assistant.message", {"content": "The answer is 42."}),
|
|
52
|
+
_event("assistant.message", {"content": "", "toolRequests": [{}]}),
|
|
53
|
+
_event("result", exitCode=0),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
self.assertEqual(self._run(_completed(stdout)), "The answer is 42.")
|
|
57
|
+
|
|
58
|
+
def test_passes_the_session_id_and_runs_headless(self) -> None:
|
|
59
|
+
stdout = _stream(_event("assistant.message", {"content": "ok"}),
|
|
60
|
+
_event("result", exitCode=0))
|
|
61
|
+
|
|
62
|
+
self._run(_completed(stdout))
|
|
63
|
+
|
|
64
|
+
cmd = self.captured.call_args.args[0]
|
|
65
|
+
self.assertEqual(cmd[:2], ["copilot", "-p"])
|
|
66
|
+
self.assertEqual(cmd[2], "/do-it CORE-1")
|
|
67
|
+
self.assertIn("--session-id", cmd)
|
|
68
|
+
self.assertEqual(cmd[cmd.index("--session-id") + 1], SESSION)
|
|
69
|
+
for flag in ("--allow-all", "--no-ask-user", "--output-format"):
|
|
70
|
+
self.assertIn(flag, cmd)
|
|
71
|
+
self.assertEqual(self.captured.call_args.kwargs["cwd"], Path("/repo"))
|
|
72
|
+
|
|
73
|
+
def test_the_skill_model_is_passed_on_the_command_line(self) -> None:
|
|
74
|
+
stdout = _stream(_event("assistant.message", {"content": "ok"}),
|
|
75
|
+
_event("result", exitCode=0))
|
|
76
|
+
|
|
77
|
+
self._run(_completed(stdout), model="claude-opus-5")
|
|
78
|
+
|
|
79
|
+
cmd = self.captured.call_args.args[0]
|
|
80
|
+
self.assertEqual(cmd[cmd.index("--model") + 1], "claude-opus-5")
|
|
81
|
+
|
|
82
|
+
def test_no_model_leaves_the_agent_on_its_default(self) -> None:
|
|
83
|
+
stdout = _stream(_event("assistant.message", {"content": "ok"}),
|
|
84
|
+
_event("result", exitCode=0))
|
|
85
|
+
|
|
86
|
+
self._run(_completed(stdout))
|
|
87
|
+
|
|
88
|
+
self.assertNotIn("--model", self.captured.call_args.args[0])
|
|
89
|
+
|
|
90
|
+
def test_a_non_zero_exit_raises_with_the_stderr_reason(self) -> None:
|
|
91
|
+
completed = _completed(
|
|
92
|
+
stderr='Error: Model "nope" from --model flag is not available.',
|
|
93
|
+
returncode=1)
|
|
94
|
+
|
|
95
|
+
with self.assertRaises(RuntimeError) as caught:
|
|
96
|
+
self._run(completed)
|
|
97
|
+
|
|
98
|
+
self.assertIn("is not available", str(caught.exception))
|
|
99
|
+
|
|
100
|
+
def test_a_failed_run_raises_with_the_session_error(self) -> None:
|
|
101
|
+
stdout = _stream(
|
|
102
|
+
_event("session.error", {"errorType": "quota", "message": "quota exceeded"}),
|
|
103
|
+
_event("result", exitCode=1),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
with self.assertRaises(RuntimeError) as caught:
|
|
107
|
+
self._run(_completed(stdout))
|
|
108
|
+
|
|
109
|
+
self.assertIn("quota exceeded", str(caught.exception))
|
|
110
|
+
|
|
111
|
+
def test_a_run_with_no_response_raises(self) -> None:
|
|
112
|
+
with self.assertRaises(RuntimeError):
|
|
113
|
+
self._run(_completed(_stream(_event("result", exitCode=0))))
|
|
114
|
+
|
|
115
|
+
def test_output_that_is_not_the_event_stream_is_passed_through(self) -> None:
|
|
116
|
+
self.assertEqual(self._run(_completed("plain text reply\n")),
|
|
117
|
+
"plain text reply\n")
|
|
118
|
+
|
|
119
|
+
def test_a_timeout_raises(self) -> None:
|
|
120
|
+
with patch("subprocess.run",
|
|
121
|
+
side_effect=subprocess.TimeoutExpired(cmd="copilot", timeout=7200)):
|
|
122
|
+
with self.assertRaises(RuntimeError):
|
|
123
|
+
self.agent.run("/do-it CORE-1", SESSION)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class CopilotModelCatalogTest(unittest.TestCase):
|
|
127
|
+
def _queue(self, *lines: str) -> "queue.Queue[str]":
|
|
128
|
+
lines_queue: queue.Queue[str] = queue.Queue()
|
|
129
|
+
for line in lines:
|
|
130
|
+
lines_queue.put(line)
|
|
131
|
+
return lines_queue
|
|
132
|
+
|
|
133
|
+
def test_reads_the_session_new_result_past_other_traffic(self) -> None:
|
|
134
|
+
lines = self._queue(
|
|
135
|
+
json.dumps({"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": 1}}),
|
|
136
|
+
json.dumps({"jsonrpc": "2.0", "method": "session/update", "params": {}}),
|
|
137
|
+
json.dumps({"jsonrpc": "2.0", "id": 2, "result": {"sessionId": "s1"}}),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
result = _await_result(Mock(poll=Mock(return_value=None)), lines, 2)
|
|
141
|
+
|
|
142
|
+
self.assertEqual(result["sessionId"], "s1")
|
|
143
|
+
|
|
144
|
+
def test_an_early_exit_raises_instead_of_waiting_out_the_deadline(self) -> None:
|
|
145
|
+
process = Mock(poll=Mock(return_value=1), returncode=1,
|
|
146
|
+
stderr=Mock(read=Mock(return_value="not logged in")))
|
|
147
|
+
|
|
148
|
+
with self.assertRaises(RuntimeError) as caught:
|
|
149
|
+
_await_result(process, self._queue(), 2)
|
|
150
|
+
|
|
151
|
+
self.assertIn("not logged in", str(caught.exception))
|
|
152
|
+
|
|
153
|
+
def test_a_catalog_becomes_id_and_name_pairs(self) -> None:
|
|
154
|
+
result = {"models": {"availableModels": [
|
|
155
|
+
{"modelId": "claude-opus-5", "name": "Claude Opus 5"},
|
|
156
|
+
{"modelId": "gpt-5.4"}, # no display name: falls back to the id
|
|
157
|
+
{"name": "nameless"}, # no id at all: unusable, skipped
|
|
158
|
+
]}}
|
|
159
|
+
|
|
160
|
+
with patch("codee_agent_github_copilot.provider.subprocess.Popen"), \
|
|
161
|
+
patch("codee_agent_github_copilot.provider._send"), \
|
|
162
|
+
patch("codee_agent_github_copilot.provider._await_result",
|
|
163
|
+
return_value=result):
|
|
164
|
+
models = GitHubCopilotAgent.list_models()
|
|
165
|
+
|
|
166
|
+
self.assertEqual([(m.id, m.name) for m in models],
|
|
167
|
+
[("claude-opus-5", "Claude Opus 5"), ("gpt-5.4", "gpt-5.4")])
|
|
168
|
+
|
|
169
|
+
def test_an_unavailable_cli_yields_no_models_rather_than_raising(self) -> None:
|
|
170
|
+
with patch("codee_agent_github_copilot.provider.subprocess.Popen",
|
|
171
|
+
side_effect=FileNotFoundError("copilot")):
|
|
172
|
+
self.assertEqual(GitHubCopilotAgent.list_models(), [])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
unittest.main()
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from sqlite3 import Connection
|
|
2
|
+
import sqlite3
|
|
3
|
+
|
|
4
|
+
from anyio import Path
|
|
5
|
+
from codee_main_context.context import CodeeMainContext
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _get_db_path(main_context: CodeeMainContext) -> Path:
|
|
9
|
+
return main_context.data_dir / "codee.db"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_db_connection(main_context: CodeeMainContext) -> Connection:
|
|
13
|
+
return sqlite3.connect(_get_db_path(main_context))
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""SQLite storage for OAuth tokens, keyed by tasks provider.
|
|
2
|
+
|
|
3
|
+
Tokens are kept out of ``settings.json`` because that file is written by the
|
|
4
|
+
admin UI on every save and is easy to hand-edit or copy around; a refresh token
|
|
5
|
+
is a long-lived credential and belongs next to the rest of the runtime state.
|
|
6
|
+
|
|
7
|
+
Unlike :mod:`codee.lib.runs_db` these calls raise rather than swallow errors:
|
|
8
|
+
losing a token write silently would leave the UI claiming a connection that
|
|
9
|
+
doesn't exist, and a read that quietly returns ``None`` would look like "never
|
|
10
|
+
connected" and send the user through the whole consent flow again.
|
|
11
|
+
"""
|
|
12
|
+
from contextlib import closing
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
|
|
15
|
+
from codee_main_context.context import CodeeMainContext
|
|
16
|
+
|
|
17
|
+
from codee_database.database import get_db_connection
|
|
18
|
+
|
|
19
|
+
# An authorization redirect that hasn't come back within this window is treated
|
|
20
|
+
# as abandoned, so a stale row can't be replayed later.
|
|
21
|
+
PENDING_TTL = timedelta(minutes=10)
|
|
22
|
+
|
|
23
|
+
_TOKEN_COLUMNS = ("provider", "access_token", "refresh_token", "expires_at",
|
|
24
|
+
"scope", "account", "updated_at")
|
|
25
|
+
_PENDING_COLUMNS = ("state", "provider", "code_verifier",
|
|
26
|
+
"redirect_uri", "created_at")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def init(main_context: CodeeMainContext) -> None:
|
|
30
|
+
"""Create the token and pending-authorization tables if absent. Idempotent."""
|
|
31
|
+
with closing(get_db_connection(main_context)) as conn, conn:
|
|
32
|
+
conn.execute(
|
|
33
|
+
"""CREATE TABLE IF NOT EXISTS oauth_tokens (
|
|
34
|
+
provider TEXT PRIMARY KEY,
|
|
35
|
+
access_token TEXT NOT NULL,
|
|
36
|
+
refresh_token TEXT,
|
|
37
|
+
expires_at TEXT,
|
|
38
|
+
scope TEXT,
|
|
39
|
+
account TEXT,
|
|
40
|
+
updated_at TEXT NOT NULL
|
|
41
|
+
)"""
|
|
42
|
+
)
|
|
43
|
+
# One row per authorization in flight: the CSRF state we handed to the
|
|
44
|
+
# identity provider, plus the PKCE verifier the callback has to send back.
|
|
45
|
+
conn.execute(
|
|
46
|
+
"""CREATE TABLE IF NOT EXISTS oauth_pending (
|
|
47
|
+
state TEXT PRIMARY KEY,
|
|
48
|
+
provider TEXT NOT NULL,
|
|
49
|
+
code_verifier TEXT NOT NULL,
|
|
50
|
+
redirect_uri TEXT NOT NULL,
|
|
51
|
+
created_at TEXT NOT NULL
|
|
52
|
+
)"""
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def save_tokens(
|
|
57
|
+
provider: str,
|
|
58
|
+
access_token: str,
|
|
59
|
+
refresh_token: str | None,
|
|
60
|
+
expires_at: str | None,
|
|
61
|
+
scope: str = "",
|
|
62
|
+
account: str = "",
|
|
63
|
+
main_context: CodeeMainContext = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Store (or replace) the tokens for a provider."""
|
|
66
|
+
init(main_context)
|
|
67
|
+
with closing(get_db_connection(main_context)) as conn, conn:
|
|
68
|
+
conn.execute(
|
|
69
|
+
"INSERT INTO oauth_tokens (provider, access_token, refresh_token,"
|
|
70
|
+
" expires_at, scope, account, updated_at)"
|
|
71
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
72
|
+
" ON CONFLICT(provider) DO UPDATE SET"
|
|
73
|
+
" access_token = excluded.access_token,"
|
|
74
|
+
" refresh_token = excluded.refresh_token,"
|
|
75
|
+
" expires_at = excluded.expires_at,"
|
|
76
|
+
" scope = excluded.scope,"
|
|
77
|
+
" account = excluded.account,"
|
|
78
|
+
" updated_at = excluded.updated_at",
|
|
79
|
+
(provider, access_token, refresh_token, expires_at, scope, account,
|
|
80
|
+
datetime.now(timezone.utc).isoformat()),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_tokens(provider: str, main_context: CodeeMainContext = None) -> dict | None:
|
|
85
|
+
"""Return the stored tokens for a provider, or None if it was never connected."""
|
|
86
|
+
init(main_context)
|
|
87
|
+
with closing(get_db_connection(main_context)) as conn:
|
|
88
|
+
row = conn.execute(
|
|
89
|
+
"SELECT provider, access_token, refresh_token, expires_at, scope,"
|
|
90
|
+
" account, updated_at FROM oauth_tokens WHERE provider = ?",
|
|
91
|
+
(provider,),
|
|
92
|
+
).fetchone()
|
|
93
|
+
return dict(zip(_TOKEN_COLUMNS, row)) if row else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def delete_tokens(provider: str, main_context: CodeeMainContext = None) -> None:
|
|
97
|
+
"""Forget a provider's tokens, so the UI reports it as disconnected."""
|
|
98
|
+
init(main_context)
|
|
99
|
+
with closing(get_db_connection(main_context)) as conn, conn:
|
|
100
|
+
conn.execute("DELETE FROM oauth_tokens WHERE provider = ?", (provider,))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def create_pending(
|
|
104
|
+
provider: str,
|
|
105
|
+
state: str,
|
|
106
|
+
code_verifier: str,
|
|
107
|
+
redirect_uri: str,
|
|
108
|
+
main_context: CodeeMainContext = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Record an authorization we're about to send the browser off to."""
|
|
111
|
+
init(main_context)
|
|
112
|
+
with closing(get_db_connection(main_context)) as conn, conn:
|
|
113
|
+
_purge_expired_pending(conn)
|
|
114
|
+
conn.execute(
|
|
115
|
+
"INSERT OR REPLACE INTO oauth_pending (state, provider, code_verifier,"
|
|
116
|
+
" redirect_uri, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
117
|
+
(state, provider, code_verifier, redirect_uri,
|
|
118
|
+
datetime.now(timezone.utc).isoformat()),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def consume_pending(
|
|
123
|
+
provider: str,
|
|
124
|
+
state: str,
|
|
125
|
+
main_context: CodeeMainContext = None,
|
|
126
|
+
) -> dict | None:
|
|
127
|
+
"""Take the pending authorization matching ``state``, removing it.
|
|
128
|
+
|
|
129
|
+
Returns None when the state is unknown, belongs to another provider, or has
|
|
130
|
+
expired — all of which mean the callback must be rejected. Single-use by
|
|
131
|
+
construction: the row is deleted whether or not the exchange later succeeds,
|
|
132
|
+
so a replayed callback finds nothing.
|
|
133
|
+
"""
|
|
134
|
+
init(main_context)
|
|
135
|
+
with closing(get_db_connection(main_context)) as conn, conn:
|
|
136
|
+
_purge_expired_pending(conn)
|
|
137
|
+
row = conn.execute(
|
|
138
|
+
"SELECT state, provider, code_verifier, redirect_uri, created_at"
|
|
139
|
+
" FROM oauth_pending WHERE state = ? AND provider = ?",
|
|
140
|
+
(state, provider),
|
|
141
|
+
).fetchone()
|
|
142
|
+
conn.execute("DELETE FROM oauth_pending WHERE state = ?", (state,))
|
|
143
|
+
return dict(zip(_PENDING_COLUMNS, row)) if row else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _purge_expired_pending(conn) -> None:
|
|
147
|
+
cutoff = (datetime.now(timezone.utc) - PENDING_TTL).isoformat()
|
|
148
|
+
conn.execute("DELETE FROM oauth_pending WHERE created_at < ?", (cutoff,))
|
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def project_root() -> Path:
|
|
9
|
+
"""Directory holding the content Codee operates on: ``.claude/skills`` and ``memory/``.
|
|
10
|
+
|
|
11
|
+
Defaults to the working directory, so a project that installs Codee as a
|
|
12
|
+
package supplies its own skills and memories rather than reaching into the
|
|
13
|
+
installed package. Override with ``CODEE_PROJECT_ROOT``.
|
|
14
|
+
"""
|
|
15
|
+
return Path(os.environ.get("CODEE_PROJECT_ROOT") or os.getcwd())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def skills_dir(root: Path | None = None) -> Path:
|
|
19
|
+
return (root or project_root()) / ".claude" / "skills"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def memory_dir(root: Path | None = None) -> Path:
|
|
23
|
+
return (root or project_root()) / "memory"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def data_dir(root: Path | None = None) -> Path:
|
|
27
|
+
"""Codee's own state directory (settings.json, runs db). Override with ``CODEE_DATA_DIR``."""
|
|
28
|
+
override = os.environ.get("CODEE_DATA_DIR")
|
|
29
|
+
return Path(override) if override else (root or project_root()) / ".codee"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TasksProvider(str, Enum):
|
|
33
|
+
JIRA = "jira"
|
|
34
|
+
AZURE_DEVOPS = "azure_devops"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CodingAgent(str, Enum):
|
|
38
|
+
CLAUDE_CODE = "claude_code"
|
|
39
|
+
GITHUB_COPILOT = "github_copilot"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class CredentialField:
|
|
44
|
+
key: str
|
|
45
|
+
label: str
|
|
46
|
+
secret: bool = False
|
|
47
|
+
default: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Credential fields each provider needs. Keyed by provider so the admin UI can
|
|
51
|
+
# render the right inputs and settings.json can store per-provider values.
|
|
52
|
+
TASKS_PROVIDER_FIELDS: dict[TasksProvider, list[CredentialField]] = {
|
|
53
|
+
TasksProvider.JIRA: [
|
|
54
|
+
CredentialField("base_url", "Base URL"),
|
|
55
|
+
CredentialField("account_email", "Account email"),
|
|
56
|
+
CredentialField("api_token", "API token", secret=True),
|
|
57
|
+
CredentialField("project", "Project key"),
|
|
58
|
+
],
|
|
59
|
+
# Azure DevOps authenticates through an Entra ID app registration, so the
|
|
60
|
+
# stored credentials describe the app; the tokens it yields live in SQLite
|
|
61
|
+
# (codee_database.oauth_tokens) rather than in settings.json.
|
|
62
|
+
TasksProvider.AZURE_DEVOPS: [
|
|
63
|
+
CredentialField("organization_url", "Organization URL"),
|
|
64
|
+
CredentialField("project", "Project"),
|
|
65
|
+
CredentialField("tenant_id", "Directory (tenant) ID"),
|
|
66
|
+
CredentialField("client_id", "Application (client) ID"),
|
|
67
|
+
CredentialField("client_secret", "Client secret", secret=True),
|
|
68
|
+
],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Settings:
|
|
74
|
+
tasks_provider: TasksProvider = TasksProvider.JIRA
|
|
75
|
+
# Which coding agent the executor drives to work on tasks.
|
|
76
|
+
coding_agent: CodingAgent = CodingAgent.CLAUDE_CODE
|
|
77
|
+
# Per-provider credentials, keyed by provider value -> {field key: value}.
|
|
78
|
+
# Values for all providers are kept so switching provider preserves them.
|
|
79
|
+
credentials: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
80
|
+
# Max coding-agent runs the executor keeps in flight at once (>= 1).
|
|
81
|
+
max_parallel_agents: int = 3
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class CodeeMainContext:
|
|
86
|
+
data_dir: Path
|
|
87
|
+
settings: Settings = field(default_factory=Settings)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def settings_file(data_dir: Path) -> Path:
|
|
91
|
+
return Path(data_dir) / "settings.json"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_settings(data_dir: Path) -> Settings:
|
|
95
|
+
"""Load Settings from ``settings.json`` in data_dir, or defaults if absent."""
|
|
96
|
+
path = settings_file(data_dir)
|
|
97
|
+
if path.exists():
|
|
98
|
+
try:
|
|
99
|
+
data = json.loads(path.read_text())
|
|
100
|
+
return Settings(
|
|
101
|
+
tasks_provider=TasksProvider(data["tasks_provider"]),
|
|
102
|
+
coding_agent=CodingAgent(
|
|
103
|
+
data.get("coding_agent", CodingAgent.CLAUDE_CODE.value)),
|
|
104
|
+
credentials=data.get("credentials", {}),
|
|
105
|
+
max_parallel_agents=max(1, int(data.get("max_parallel_agents", 3))))
|
|
106
|
+
except (json.JSONDecodeError, OSError, KeyError, ValueError):
|
|
107
|
+
pass
|
|
108
|
+
return Settings()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def save_settings(data_dir: Path, settings: Settings) -> None:
|
|
112
|
+
"""Persist Settings to ``settings.json`` in data_dir.
|
|
113
|
+
|
|
114
|
+
Written through a temp file and renamed into place: the executor re-reads
|
|
115
|
+
this file on every poll, and a torn read there would look like "no settings"
|
|
116
|
+
and silently reset it to the defaults.
|
|
117
|
+
"""
|
|
118
|
+
path = settings_file(data_dir)
|
|
119
|
+
payload = json.dumps({
|
|
120
|
+
"tasks_provider": settings.tasks_provider.value,
|
|
121
|
+
"coding_agent": settings.coding_agent.value,
|
|
122
|
+
"credentials": settings.credentials,
|
|
123
|
+
"max_parallel_agents": settings.max_parallel_agents,
|
|
124
|
+
}, indent=2) + "\n"
|
|
125
|
+
temp = path.with_name(path.name + ".tmp")
|
|
126
|
+
temp.write_text(payload)
|
|
127
|
+
os.replace(temp, path)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Process-wide logging for Codee's entry points.
|
|
2
|
+
|
|
3
|
+
Codee runs as several processes -- ``codee-start`` launches the executor and
|
|
4
|
+
the admin UI as subprocesses -- so debug mode is carried in the environment:
|
|
5
|
+
``CODEE_DEBUG=1`` switches this package to ``DEBUG`` level, and because
|
|
6
|
+
subprocesses inherit the environment, one flag on the launcher turns on debug
|
|
7
|
+
logging everywhere.
|
|
8
|
+
|
|
9
|
+
Logging in any module::
|
|
10
|
+
|
|
11
|
+
from codee_main_context.logging import get_logger
|
|
12
|
+
|
|
13
|
+
log = get_logger(__name__)
|
|
14
|
+
log.debug("fetched %d task(s) from %s", len(tasks), provider.describe())
|
|
15
|
+
|
|
16
|
+
Only entry points (``main()`` functions) call :func:`configure_logging`, and
|
|
17
|
+
they call it once, before doing any work. Everything else just asks for a
|
|
18
|
+
logger and logs; the handler is the entry point's business.
|
|
19
|
+
"""
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from typing import TextIO
|
|
24
|
+
|
|
25
|
+
# Set to "1"/"true"/"yes"/"on" for Codee debug output, or "all" to also
|
|
26
|
+
# un-mute the chatty third-party libraries listed below.
|
|
27
|
+
DEBUG_ENV_VAR = "CODEE_DEBUG"
|
|
28
|
+
|
|
29
|
+
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
30
|
+
_VERBOSE = "all"
|
|
31
|
+
|
|
32
|
+
# Libraries whose DEBUG output would bury ours (a single boto3 call emits
|
|
33
|
+
# dozens of lines). Held at INFO unless CODEE_DEBUG=all asks for everything.
|
|
34
|
+
_NOISY_LOGGERS = (
|
|
35
|
+
"asyncio",
|
|
36
|
+
"boto3",
|
|
37
|
+
"botocore",
|
|
38
|
+
"httpcore",
|
|
39
|
+
"httpx",
|
|
40
|
+
"reflex",
|
|
41
|
+
"s3transfer",
|
|
42
|
+
"urllib3",
|
|
43
|
+
"watchdog",
|
|
44
|
+
"watchfiles",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_FORMAT = "%(asctime)s %(levelname)-7s %(name)s: %(message)s"
|
|
48
|
+
_DEBUG_FORMAT = "%(asctime)s %(levelname)-7s %(name)s:%(lineno)d %(funcName)s: %(message)s"
|
|
49
|
+
_DATE_FORMAT = "%H:%M:%S"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _flag() -> str:
|
|
53
|
+
return os.environ.get(DEBUG_ENV_VAR, "").strip().lower()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def debug_enabled() -> bool:
|
|
57
|
+
"""True when ``CODEE_DEBUG`` asks for debug output."""
|
|
58
|
+
return _flag() in _TRUTHY or _flag() == _VERBOSE
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def verbose_debug_enabled() -> bool:
|
|
62
|
+
"""True for ``CODEE_DEBUG=all``: our debug output plus the noisy libraries'."""
|
|
63
|
+
return _flag() == _VERBOSE
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def enable_debug(*, verbose: bool = False) -> None:
|
|
67
|
+
"""Turn debug on for this process and every subprocess it goes on to spawn.
|
|
68
|
+
|
|
69
|
+
Writing to ``os.environ`` rather than to a module global is what makes
|
|
70
|
+
``codee-start --debug`` reach the executor and admin processes, which are
|
|
71
|
+
separate interpreters that inherit the environment.
|
|
72
|
+
"""
|
|
73
|
+
os.environ[DEBUG_ENV_VAR] = _VERBOSE if verbose else "1"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def configure_logging(debug: bool | None = None, *,
|
|
77
|
+
stream: TextIO | None = None) -> None:
|
|
78
|
+
"""Install Codee's log handler. Call once, from an entry point.
|
|
79
|
+
|
|
80
|
+
``debug=None`` (the default) takes the level from ``CODEE_DEBUG``; passing
|
|
81
|
+
``debug=True`` also exports the flag so subprocesses inherit it. Safe to
|
|
82
|
+
call more than once -- the previous handler is replaced, not stacked.
|
|
83
|
+
"""
|
|
84
|
+
if debug:
|
|
85
|
+
enable_debug()
|
|
86
|
+
if debug is None:
|
|
87
|
+
debug = debug_enabled()
|
|
88
|
+
|
|
89
|
+
logging.basicConfig(
|
|
90
|
+
level=logging.DEBUG if debug else logging.INFO,
|
|
91
|
+
format=_DEBUG_FORMAT if debug else _FORMAT,
|
|
92
|
+
datefmt=_DATE_FORMAT,
|
|
93
|
+
# stdout, not the logging default of stderr: the code around these logs
|
|
94
|
+
# still prints to stdout, and splitting the two streams would scramble
|
|
95
|
+
# their order as soon as the output is piped to a file.
|
|
96
|
+
stream=stream or sys.stdout,
|
|
97
|
+
force=True,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if not verbose_debug_enabled():
|
|
101
|
+
for name in _NOISY_LOGGERS:
|
|
102
|
+
logging.getLogger(name).setLevel(logging.INFO)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_logger(name: str) -> logging.Logger:
|
|
106
|
+
"""Logger for a module; pass ``__name__``.
|
|
107
|
+
|
|
108
|
+
A thin wrapper over ``logging.getLogger`` so modules have one import for
|
|
109
|
+
logging and never touch handler configuration by accident.
|
|
110
|
+
"""
|
|
111
|
+
return logging.getLogger(name)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import unittest
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from codee_main_context.logging import (
|
|
8
|
+
DEBUG_ENV_VAR, configure_logging, debug_enabled, enable_debug, get_logger,
|
|
9
|
+
verbose_debug_enabled)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LoggingTest(unittest.TestCase):
|
|
13
|
+
def setUp(self) -> None:
|
|
14
|
+
root = logging.getLogger()
|
|
15
|
+
self._handlers = root.handlers[:]
|
|
16
|
+
self._level = root.level
|
|
17
|
+
self.stream = io.StringIO()
|
|
18
|
+
|
|
19
|
+
def tearDown(self) -> None:
|
|
20
|
+
root = logging.getLogger()
|
|
21
|
+
root.handlers[:] = self._handlers
|
|
22
|
+
root.setLevel(self._level)
|
|
23
|
+
logging.getLogger("botocore").setLevel(logging.NOTSET)
|
|
24
|
+
|
|
25
|
+
def test_debug_enabled_reads_the_environment(self) -> None:
|
|
26
|
+
for value in ("1", "true", "TRUE", "yes", " on ", "all"):
|
|
27
|
+
with patch.dict(os.environ, {DEBUG_ENV_VAR: value}):
|
|
28
|
+
self.assertTrue(debug_enabled(), value)
|
|
29
|
+
|
|
30
|
+
for value in ("", "0", "false", "no"):
|
|
31
|
+
with patch.dict(os.environ, {DEBUG_ENV_VAR: value}):
|
|
32
|
+
self.assertFalse(debug_enabled(), value)
|
|
33
|
+
|
|
34
|
+
def test_verbose_only_for_all(self) -> None:
|
|
35
|
+
with patch.dict(os.environ, {DEBUG_ENV_VAR: "1"}):
|
|
36
|
+
self.assertFalse(verbose_debug_enabled())
|
|
37
|
+
with patch.dict(os.environ, {DEBUG_ENV_VAR: "all"}):
|
|
38
|
+
self.assertTrue(verbose_debug_enabled())
|
|
39
|
+
|
|
40
|
+
def test_enable_debug_exports_the_flag_for_subprocesses(self) -> None:
|
|
41
|
+
with patch.dict(os.environ, {}, clear=False):
|
|
42
|
+
os.environ.pop(DEBUG_ENV_VAR, None)
|
|
43
|
+
enable_debug()
|
|
44
|
+
self.assertEqual(os.environ[DEBUG_ENV_VAR], "1")
|
|
45
|
+
enable_debug(verbose=True)
|
|
46
|
+
self.assertEqual(os.environ[DEBUG_ENV_VAR], "all")
|
|
47
|
+
|
|
48
|
+
def test_debug_messages_are_dropped_by_default(self) -> None:
|
|
49
|
+
with patch.dict(os.environ, {DEBUG_ENV_VAR: ""}):
|
|
50
|
+
configure_logging(stream=self.stream)
|
|
51
|
+
log = get_logger("codee.sample")
|
|
52
|
+
log.debug("hidden detail")
|
|
53
|
+
log.info("visible line")
|
|
54
|
+
|
|
55
|
+
output = self.stream.getvalue()
|
|
56
|
+
self.assertNotIn("hidden detail", output)
|
|
57
|
+
self.assertIn("visible line", output)
|
|
58
|
+
|
|
59
|
+
def test_debug_messages_appear_when_enabled(self) -> None:
|
|
60
|
+
with patch.dict(os.environ, {DEBUG_ENV_VAR: "1"}):
|
|
61
|
+
configure_logging(stream=self.stream)
|
|
62
|
+
get_logger("codee.sample").debug("task %s queued", "NIM-1")
|
|
63
|
+
|
|
64
|
+
output = self.stream.getvalue()
|
|
65
|
+
self.assertIn("DEBUG", output)
|
|
66
|
+
self.assertIn("task NIM-1 queued", output)
|
|
67
|
+
self.assertIn("codee.sample", output)
|
|
68
|
+
|
|
69
|
+
def test_explicit_debug_also_exports_the_flag(self) -> None:
|
|
70
|
+
with patch.dict(os.environ, {}, clear=False):
|
|
71
|
+
os.environ.pop(DEBUG_ENV_VAR, None)
|
|
72
|
+
configure_logging(debug=True, stream=self.stream)
|
|
73
|
+
self.assertEqual(os.environ[DEBUG_ENV_VAR], "1")
|
|
74
|
+
self.assertEqual(logging.getLogger().level, logging.DEBUG)
|
|
75
|
+
|
|
76
|
+
def test_noisy_libraries_stay_quiet_until_debug_all(self) -> None:
|
|
77
|
+
with patch.dict(os.environ, {DEBUG_ENV_VAR: "1"}):
|
|
78
|
+
configure_logging(stream=self.stream)
|
|
79
|
+
self.assertEqual(logging.getLogger(
|
|
80
|
+
"botocore").level, logging.INFO)
|
|
81
|
+
|
|
82
|
+
with patch.dict(os.environ, {DEBUG_ENV_VAR: "all"}):
|
|
83
|
+
logging.getLogger("botocore").setLevel(logging.NOTSET)
|
|
84
|
+
configure_logging(stream=self.stream)
|
|
85
|
+
self.assertEqual(logging.getLogger(
|
|
86
|
+
"botocore").level, logging.NOTSET)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
unittest.main()
|
|
File without changes
|