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,462 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import unittest
|
|
3
|
+
from contextlib import closing
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import Mock, patch
|
|
7
|
+
|
|
8
|
+
from codee_database import oauth_tokens
|
|
9
|
+
from codee_database.database import get_db_connection
|
|
10
|
+
from codee_main_context.context import CodeeMainContext, Settings, TasksProvider
|
|
11
|
+
|
|
12
|
+
from codee_tasks_azure_devops import oauth
|
|
13
|
+
from codee_tasks_azure_devops.oauth import (
|
|
14
|
+
AzureDevOpsAuth, AzureDevOpsAuthError, OAuthConfig, is_expired)
|
|
15
|
+
from codee_tasks_azure_devops.provider import AzureDevOpsTasksProvider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _config(**overrides) -> OAuthConfig:
|
|
19
|
+
values = {
|
|
20
|
+
"organization_url": "https://dev.azure.com/acme",
|
|
21
|
+
"project": "Core",
|
|
22
|
+
"tenant_id": "tenant-1",
|
|
23
|
+
"client_id": "client-1",
|
|
24
|
+
"client_secret": "secret-1",
|
|
25
|
+
}
|
|
26
|
+
values.update(overrides)
|
|
27
|
+
return OAuthConfig(**values)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _response(payload: dict, status_code: int = 200) -> Mock:
|
|
31
|
+
response = Mock()
|
|
32
|
+
response.status_code = status_code
|
|
33
|
+
response.json.return_value = payload
|
|
34
|
+
response.raise_for_status.return_value = None
|
|
35
|
+
return response
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _iso(offset: timedelta) -> str:
|
|
39
|
+
return (datetime.now(timezone.utc) + offset).isoformat()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OAuthConfigTest(unittest.TestCase):
|
|
43
|
+
def test_from_settings_reads_provider_credentials(self) -> None:
|
|
44
|
+
settings = Settings(
|
|
45
|
+
tasks_provider=TasksProvider.AZURE_DEVOPS,
|
|
46
|
+
credentials={"azure_devops": {
|
|
47
|
+
"organization_url": "https://dev.azure.com/acme/",
|
|
48
|
+
"project": "Core",
|
|
49
|
+
"client_id": "client-1",
|
|
50
|
+
"client_secret": "secret-1",
|
|
51
|
+
}})
|
|
52
|
+
|
|
53
|
+
config = OAuthConfig.from_settings(settings)
|
|
54
|
+
|
|
55
|
+
# The trailing slash would otherwise double up in every API URL.
|
|
56
|
+
self.assertEqual(config.organization_url, "https://dev.azure.com/acme")
|
|
57
|
+
self.assertTrue(config.is_complete())
|
|
58
|
+
|
|
59
|
+
def test_is_incomplete_without_a_secret(self) -> None:
|
|
60
|
+
self.assertFalse(_config(client_secret="").is_complete())
|
|
61
|
+
|
|
62
|
+
def test_missing_tenant_falls_back_to_any_work_directory(self) -> None:
|
|
63
|
+
config = _config(tenant_id="")
|
|
64
|
+
self.assertIn("/organizations/", config.authorize_endpoint)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AuthorizationUrlTest(unittest.TestCase):
|
|
68
|
+
def test_url_carries_pkce_challenge_and_read_scope(self) -> None:
|
|
69
|
+
verifier = "verifier-value"
|
|
70
|
+
|
|
71
|
+
url = oauth.build_authorization_url(
|
|
72
|
+
_config(), "http://localhost:8501/cb", "state-1", verifier)
|
|
73
|
+
|
|
74
|
+
self.assertIn("login.microsoftonline.com/tenant-1/oauth2/v2.0/authorize", url)
|
|
75
|
+
self.assertIn(f"code_challenge={oauth.code_challenge_for(verifier)}", url)
|
|
76
|
+
self.assertIn("code_challenge_method=S256", url)
|
|
77
|
+
self.assertIn("offline_access", url)
|
|
78
|
+
self.assertIn(oauth.AZURE_DEVOPS_RESOURCE_ID, url)
|
|
79
|
+
# The verifier itself must never leave the backend.
|
|
80
|
+
self.assertNotIn(verifier, url)
|
|
81
|
+
|
|
82
|
+
def test_code_challenge_is_unpadded_url_safe_base64(self) -> None:
|
|
83
|
+
challenge = oauth.code_challenge_for("verifier-value")
|
|
84
|
+
self.assertNotIn("=", challenge)
|
|
85
|
+
self.assertNotIn("+", challenge)
|
|
86
|
+
self.assertNotIn("/", challenge)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TokenExchangeTest(unittest.TestCase):
|
|
90
|
+
def test_exchange_converts_expires_in_to_an_absolute_deadline(self) -> None:
|
|
91
|
+
with patch.object(oauth.requests, "post",
|
|
92
|
+
return_value=_response({"access_token": "at", "refresh_token": "rt",
|
|
93
|
+
"expires_in": 3599})) as post:
|
|
94
|
+
tokens = oauth.exchange_code(
|
|
95
|
+
_config(), "http://localhost:8501/cb", "code-1", "verifier-1")
|
|
96
|
+
|
|
97
|
+
sent = post.call_args.kwargs["data"]
|
|
98
|
+
self.assertEqual(sent["grant_type"], "authorization_code")
|
|
99
|
+
self.assertEqual(sent["code_verifier"], "verifier-1")
|
|
100
|
+
self.assertEqual(sent["client_secret"], "secret-1")
|
|
101
|
+
self.assertEqual(tokens["refresh_token"], "rt")
|
|
102
|
+
deadline = datetime.fromisoformat(tokens["expires_at"])
|
|
103
|
+
self.assertGreater(deadline, datetime.now(timezone.utc))
|
|
104
|
+
|
|
105
|
+
def test_error_response_raises_with_the_first_description_line(self) -> None:
|
|
106
|
+
payload = {"error": "invalid_client",
|
|
107
|
+
"error_description": "AADSTS7000215: Invalid client secret.\r\n"
|
|
108
|
+
"Trace ID: abc\r\nCorrelation ID: def"}
|
|
109
|
+
with patch.object(oauth.requests, "post",
|
|
110
|
+
return_value=_response(payload, status_code=401)):
|
|
111
|
+
with self.assertRaises(AzureDevOpsAuthError) as raised:
|
|
112
|
+
oauth.exchange_code(_config(), "http://cb", "code", "verifier")
|
|
113
|
+
|
|
114
|
+
self.assertEqual(str(raised.exception),
|
|
115
|
+
"AADSTS7000215: Invalid client secret.")
|
|
116
|
+
# Fixable in Settings; the refresh token is still good.
|
|
117
|
+
self.assertFalse(raised.exception.terminal)
|
|
118
|
+
|
|
119
|
+
def test_revoked_grant_is_terminal(self) -> None:
|
|
120
|
+
payload = {"error": "invalid_grant",
|
|
121
|
+
"error_description": "AADSTS700082: The refresh token has expired."}
|
|
122
|
+
with patch.object(oauth.requests, "post",
|
|
123
|
+
return_value=_response(payload, status_code=400)):
|
|
124
|
+
with self.assertRaises(AzureDevOpsAuthError) as raised:
|
|
125
|
+
oauth.refresh_access_token(_config(), "rt")
|
|
126
|
+
|
|
127
|
+
self.assertTrue(raised.exception.terminal)
|
|
128
|
+
|
|
129
|
+
def test_entra_outage_is_not_terminal(self) -> None:
|
|
130
|
+
for status_code in (500, 503, 429):
|
|
131
|
+
with self.subTest(status_code=status_code):
|
|
132
|
+
with patch.object(oauth.requests, "post",
|
|
133
|
+
return_value=_response({}, status_code=status_code)):
|
|
134
|
+
with self.assertRaises(AzureDevOpsAuthError) as raised:
|
|
135
|
+
oauth.refresh_access_token(_config(), "rt")
|
|
136
|
+
|
|
137
|
+
self.assertFalse(raised.exception.terminal)
|
|
138
|
+
|
|
139
|
+
def test_unreachable_entra_is_not_terminal(self) -> None:
|
|
140
|
+
with patch.object(oauth.requests, "post",
|
|
141
|
+
side_effect=oauth.requests.ConnectionError("no route to host")):
|
|
142
|
+
with self.assertRaises(AzureDevOpsAuthError) as raised:
|
|
143
|
+
oauth.refresh_access_token(_config(), "rt")
|
|
144
|
+
|
|
145
|
+
self.assertFalse(raised.exception.terminal)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ExpiryTest(unittest.TestCase):
|
|
149
|
+
def test_token_inside_the_safety_margin_counts_as_expired(self) -> None:
|
|
150
|
+
self.assertTrue(is_expired(_iso(timedelta(seconds=30))))
|
|
151
|
+
|
|
152
|
+
def test_token_with_time_to_spare_is_not_expired(self) -> None:
|
|
153
|
+
self.assertFalse(is_expired(_iso(timedelta(minutes=30))))
|
|
154
|
+
|
|
155
|
+
def test_unusable_expiry_forces_a_refresh(self) -> None:
|
|
156
|
+
self.assertTrue(is_expired(None))
|
|
157
|
+
self.assertTrue(is_expired("not-a-timestamp"))
|
|
158
|
+
|
|
159
|
+
def test_naive_timestamp_is_read_as_utc(self) -> None:
|
|
160
|
+
naive = (datetime.now(timezone.utc)
|
|
161
|
+
+ timedelta(minutes=30)).replace(tzinfo=None).isoformat()
|
|
162
|
+
self.assertFalse(is_expired(naive))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class AuthTokenLifecycleTest(unittest.TestCase):
|
|
166
|
+
def setUp(self) -> None:
|
|
167
|
+
self._temporary = tempfile.TemporaryDirectory()
|
|
168
|
+
self.addCleanup(self._temporary.cleanup)
|
|
169
|
+
self.context = CodeeMainContext(data_dir=Path(self._temporary.name))
|
|
170
|
+
self.auth = AzureDevOpsAuth(_config(), self.context)
|
|
171
|
+
|
|
172
|
+
def _store(self, expires_at: str, refresh_token: str | None = "rt") -> None:
|
|
173
|
+
oauth_tokens.save_tokens(
|
|
174
|
+
oauth.PROVIDER, access_token="at", refresh_token=refresh_token,
|
|
175
|
+
expires_at=expires_at, account="dev@acme.com",
|
|
176
|
+
main_context=self.context)
|
|
177
|
+
|
|
178
|
+
def test_unconnected_provider_reports_no_connection(self) -> None:
|
|
179
|
+
self.assertFalse(self.auth.is_connected())
|
|
180
|
+
with self.assertRaises(AzureDevOpsAuthError):
|
|
181
|
+
self.auth.access_token()
|
|
182
|
+
|
|
183
|
+
def test_valid_token_is_returned_without_calling_entra(self) -> None:
|
|
184
|
+
self._store(_iso(timedelta(minutes=30)))
|
|
185
|
+
|
|
186
|
+
with patch.object(oauth, "refresh_access_token") as refresh:
|
|
187
|
+
self.assertEqual(self.auth.access_token(), "at")
|
|
188
|
+
|
|
189
|
+
refresh.assert_not_called()
|
|
190
|
+
|
|
191
|
+
def test_expired_token_is_refreshed_and_persisted(self) -> None:
|
|
192
|
+
self._store(_iso(timedelta(seconds=-10)))
|
|
193
|
+
fresh = {"access_token": "at2", "refresh_token": "rt2",
|
|
194
|
+
"expires_at": _iso(timedelta(hours=1)), "scope": oauth.SCOPE}
|
|
195
|
+
|
|
196
|
+
with patch.object(oauth, "refresh_access_token", return_value=fresh) as refresh:
|
|
197
|
+
self.assertEqual(self.auth.access_token(), "at2")
|
|
198
|
+
|
|
199
|
+
refresh.assert_called_once()
|
|
200
|
+
stored = self.auth.connection()
|
|
201
|
+
self.assertEqual(stored["access_token"], "at2")
|
|
202
|
+
self.assertEqual(stored["refresh_token"], "rt2")
|
|
203
|
+
# The account label survives a refresh; Entra doesn't resend it.
|
|
204
|
+
self.assertEqual(stored["account"], "dev@acme.com")
|
|
205
|
+
|
|
206
|
+
def test_refresh_without_a_new_refresh_token_keeps_the_old_one(self) -> None:
|
|
207
|
+
self._store(_iso(timedelta(seconds=-10)))
|
|
208
|
+
fresh = {"access_token": "at2", "refresh_token": None,
|
|
209
|
+
"expires_at": _iso(timedelta(hours=1))}
|
|
210
|
+
|
|
211
|
+
with patch.object(oauth, "refresh_access_token", return_value=fresh):
|
|
212
|
+
self.auth.access_token()
|
|
213
|
+
|
|
214
|
+
self.assertEqual(self.auth.connection()["refresh_token"], "rt")
|
|
215
|
+
|
|
216
|
+
def test_rejected_refresh_token_drops_the_connection(self) -> None:
|
|
217
|
+
self._store(_iso(timedelta(seconds=-10)))
|
|
218
|
+
|
|
219
|
+
with patch.object(oauth, "refresh_access_token",
|
|
220
|
+
side_effect=AzureDevOpsAuthError("invalid_grant",
|
|
221
|
+
terminal=True)):
|
|
222
|
+
with self.assertRaises(AzureDevOpsAuthError):
|
|
223
|
+
self.auth.access_token()
|
|
224
|
+
|
|
225
|
+
# Left connected, every poll would retry a token Entra will never accept.
|
|
226
|
+
self.assertFalse(self.auth.is_connected())
|
|
227
|
+
|
|
228
|
+
def test_transient_refresh_failure_keeps_the_connection(self) -> None:
|
|
229
|
+
# An Entra outage or a dropped network must not cost a manual reconsent.
|
|
230
|
+
self._store(_iso(timedelta(seconds=-10)))
|
|
231
|
+
|
|
232
|
+
with patch.object(oauth, "refresh_access_token",
|
|
233
|
+
side_effect=AzureDevOpsAuthError("Could not reach Entra ID")):
|
|
234
|
+
with self.assertRaises(AzureDevOpsAuthError):
|
|
235
|
+
self.auth.access_token()
|
|
236
|
+
|
|
237
|
+
self.assertTrue(self.auth.is_connected())
|
|
238
|
+
self.assertEqual(self.auth.connection()["refresh_token"], "rt")
|
|
239
|
+
|
|
240
|
+
def test_connection_survives_a_transient_failure_and_recovers(self) -> None:
|
|
241
|
+
self._store(_iso(timedelta(seconds=-10)))
|
|
242
|
+
fresh = {"access_token": "at2", "refresh_token": "rt2",
|
|
243
|
+
"expires_at": _iso(timedelta(hours=1))}
|
|
244
|
+
|
|
245
|
+
with patch.object(oauth, "refresh_access_token",
|
|
246
|
+
side_effect=[AzureDevOpsAuthError("Entra ID is down"), fresh]):
|
|
247
|
+
with self.assertRaises(AzureDevOpsAuthError):
|
|
248
|
+
self.auth.access_token()
|
|
249
|
+
self.assertEqual(self.auth.access_token(), "at2")
|
|
250
|
+
|
|
251
|
+
def test_expired_token_without_a_refresh_token_drops_the_connection(self) -> None:
|
|
252
|
+
self._store(_iso(timedelta(seconds=-10)), refresh_token=None)
|
|
253
|
+
|
|
254
|
+
with self.assertRaises(AzureDevOpsAuthError):
|
|
255
|
+
self.auth.access_token()
|
|
256
|
+
|
|
257
|
+
self.assertFalse(self.auth.is_connected())
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class PendingAuthorizationTest(unittest.TestCase):
|
|
261
|
+
def setUp(self) -> None:
|
|
262
|
+
self._temporary = tempfile.TemporaryDirectory()
|
|
263
|
+
self.addCleanup(self._temporary.cleanup)
|
|
264
|
+
self.context = CodeeMainContext(data_dir=Path(self._temporary.name))
|
|
265
|
+
|
|
266
|
+
def test_pending_state_round_trips_once(self) -> None:
|
|
267
|
+
oauth_tokens.create_pending(
|
|
268
|
+
oauth.PROVIDER, "state-1", "verifier-1", "http://localhost:8501/cb",
|
|
269
|
+
main_context=self.context)
|
|
270
|
+
|
|
271
|
+
first = oauth_tokens.consume_pending(
|
|
272
|
+
oauth.PROVIDER, "state-1", main_context=self.context)
|
|
273
|
+
replayed = oauth_tokens.consume_pending(
|
|
274
|
+
oauth.PROVIDER, "state-1", main_context=self.context)
|
|
275
|
+
|
|
276
|
+
self.assertEqual(first["code_verifier"], "verifier-1")
|
|
277
|
+
self.assertEqual(first["redirect_uri"], "http://localhost:8501/cb")
|
|
278
|
+
self.assertIsNone(replayed)
|
|
279
|
+
|
|
280
|
+
def test_unknown_state_is_rejected(self) -> None:
|
|
281
|
+
self.assertIsNone(oauth_tokens.consume_pending(
|
|
282
|
+
oauth.PROVIDER, "forged-state", main_context=self.context))
|
|
283
|
+
|
|
284
|
+
def test_expired_pending_row_is_rejected(self) -> None:
|
|
285
|
+
oauth_tokens.create_pending(
|
|
286
|
+
oauth.PROVIDER, "state-1", "verifier-1", "http://cb",
|
|
287
|
+
main_context=self.context)
|
|
288
|
+
stale = (datetime.now(timezone.utc)
|
|
289
|
+
- oauth_tokens.PENDING_TTL - timedelta(minutes=1)).isoformat()
|
|
290
|
+
with closing(get_db_connection(self.context)) as conn, conn:
|
|
291
|
+
conn.execute("UPDATE oauth_pending SET created_at = ?", (stale,))
|
|
292
|
+
|
|
293
|
+
self.assertIsNone(oauth_tokens.consume_pending(
|
|
294
|
+
oauth.PROVIDER, "state-1", main_context=self.context))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class TasksProviderTest(unittest.TestCase):
|
|
298
|
+
def setUp(self) -> None:
|
|
299
|
+
self._temporary = tempfile.TemporaryDirectory()
|
|
300
|
+
self.addCleanup(self._temporary.cleanup)
|
|
301
|
+
self.context = CodeeMainContext(data_dir=Path(self._temporary.name))
|
|
302
|
+
settings = Settings(
|
|
303
|
+
tasks_provider=TasksProvider.AZURE_DEVOPS,
|
|
304
|
+
credentials={"azure_devops": {
|
|
305
|
+
"organization_url": "https://dev.azure.com/acme",
|
|
306
|
+
"project": "Core",
|
|
307
|
+
"client_id": "client-1",
|
|
308
|
+
"client_secret": "secret-1",
|
|
309
|
+
}})
|
|
310
|
+
self.provider = AzureDevOpsTasksProvider(settings, self.context)
|
|
311
|
+
|
|
312
|
+
def _connect(self) -> None:
|
|
313
|
+
oauth_tokens.save_tokens(
|
|
314
|
+
oauth.PROVIDER, access_token="at", refresh_token="rt",
|
|
315
|
+
expires_at=_iso(timedelta(hours=1)), account="dev@acme.com",
|
|
316
|
+
main_context=self.context)
|
|
317
|
+
|
|
318
|
+
def test_wiql_quotes_statuses_and_orders_by_priority(self) -> None:
|
|
319
|
+
wiql = self.provider._build_wiql(["Ready", "Bob's queue"])
|
|
320
|
+
|
|
321
|
+
self.assertIn("[System.State] IN ('Ready', 'Bob''s queue')", wiql)
|
|
322
|
+
self.assertIn("[System.AssignedTo] = @Me", wiql)
|
|
323
|
+
self.assertIn("ORDER BY [Microsoft.VSTS.Common.Priority] ASC", wiql)
|
|
324
|
+
|
|
325
|
+
def test_wiql_only_asks_for_codee_work_item_types(self) -> None:
|
|
326
|
+
wiql = self.provider._build_wiql(["Ready"])
|
|
327
|
+
|
|
328
|
+
self.assertIn(
|
|
329
|
+
"[System.WorkItemType] IN ('Codee Task', 'Codee Story')", wiql)
|
|
330
|
+
|
|
331
|
+
def test_not_configured_until_oauth_completes(self) -> None:
|
|
332
|
+
self.assertFalse(self.provider.is_configured())
|
|
333
|
+
self._connect()
|
|
334
|
+
self.assertTrue(self.provider.is_configured())
|
|
335
|
+
|
|
336
|
+
def test_no_statuses_means_no_requests(self) -> None:
|
|
337
|
+
self._connect()
|
|
338
|
+
with patch.object(oauth.requests, "post") as post:
|
|
339
|
+
self.assertEqual(self.provider.get_tasks([]), [])
|
|
340
|
+
post.assert_not_called()
|
|
341
|
+
|
|
342
|
+
def test_get_tasks_maps_fields_and_keeps_the_query_order(self) -> None:
|
|
343
|
+
self._connect()
|
|
344
|
+
wiql = _response({"workItems": [{"id": 11}, {"id": 12}]})
|
|
345
|
+
# Deliberately out of order: the batch endpoint doesn't preserve WIQL order.
|
|
346
|
+
items = _response({"value": [
|
|
347
|
+
{"id": 12, "fields": {"System.Title": "Second",
|
|
348
|
+
"System.State": "Ready",
|
|
349
|
+
"System.WorkItemType": "Codee Task",
|
|
350
|
+
"Microsoft.VSTS.Common.Priority": 2,
|
|
351
|
+
"System.Tags": "ai; backend"}},
|
|
352
|
+
{"id": 11, "fields": {"System.Title": "First",
|
|
353
|
+
"System.State": "Ready",
|
|
354
|
+
"System.WorkItemType": "Codee Task",
|
|
355
|
+
"Microsoft.VSTS.Common.Priority": 1,
|
|
356
|
+
"System.Parent": 9}},
|
|
357
|
+
]})
|
|
358
|
+
parents = _response({"value": [
|
|
359
|
+
{"id": 9, "fields": {"System.Title": "Story",
|
|
360
|
+
"System.State": "Active",
|
|
361
|
+
"System.WorkItemType": "User Story",
|
|
362
|
+
"System.Tags": "epic"}},
|
|
363
|
+
]})
|
|
364
|
+
|
|
365
|
+
with patch("codee_tasks_azure_devops.provider.requests.post",
|
|
366
|
+
side_effect=[wiql, items, parents]):
|
|
367
|
+
tasks = self.provider.get_tasks(["Ready"])
|
|
368
|
+
|
|
369
|
+
self.assertEqual([task.key for task in tasks], ["11", "12"])
|
|
370
|
+
self.assertEqual(tasks[0].summary, "First")
|
|
371
|
+
self.assertEqual(tasks[0].priority, "Highest")
|
|
372
|
+
self.assertEqual(tasks[0].issue_type, "Task")
|
|
373
|
+
self.assertEqual(tasks[0].parent.key, "9")
|
|
374
|
+
self.assertEqual(tasks[0].parent.labels, ["epic"])
|
|
375
|
+
# A parent outside the Codee types keeps whatever Azure DevOps calls it.
|
|
376
|
+
self.assertEqual(tasks[0].parent.issue_type, "User Story")
|
|
377
|
+
self.assertEqual(tasks[1].labels, ["ai", "backend"])
|
|
378
|
+
self.assertEqual(tasks[1].priority, "High")
|
|
379
|
+
|
|
380
|
+
def test_codee_story_maps_to_the_story_issue_type(self) -> None:
|
|
381
|
+
self._connect()
|
|
382
|
+
wiql = _response({"workItems": [{"id": 21}]})
|
|
383
|
+
items = _response({"value": [
|
|
384
|
+
{"id": 21, "fields": {"System.Title": "A story",
|
|
385
|
+
"System.State": "Ready",
|
|
386
|
+
"System.WorkItemType": "Codee Story"}},
|
|
387
|
+
]})
|
|
388
|
+
|
|
389
|
+
with patch("codee_tasks_azure_devops.provider.requests.post",
|
|
390
|
+
side_effect=[wiql, items]):
|
|
391
|
+
tasks = self.provider.get_tasks(["Ready"])
|
|
392
|
+
|
|
393
|
+
self.assertEqual(tasks[0].issue_type, "Story")
|
|
394
|
+
|
|
395
|
+
def test_a_child_of_a_codee_story_is_flagged(self) -> None:
|
|
396
|
+
self._connect()
|
|
397
|
+
tasks = self._tasks_with_parent_type("Codee Story")
|
|
398
|
+
|
|
399
|
+
self.assertTrue(tasks[0].is_parent_codee_story)
|
|
400
|
+
|
|
401
|
+
def test_a_child_of_a_plain_story_is_not_flagged(self) -> None:
|
|
402
|
+
self._connect()
|
|
403
|
+
# Both types map to the "Story" issue type, so only the raw work item
|
|
404
|
+
# type separates a Codee story from a story a human owns.
|
|
405
|
+
tasks = self._tasks_with_parent_type("Story")
|
|
406
|
+
|
|
407
|
+
self.assertEqual(tasks[0].parent.issue_type, "Story")
|
|
408
|
+
self.assertFalse(tasks[0].is_parent_codee_story)
|
|
409
|
+
|
|
410
|
+
def test_a_task_without_a_parent_is_not_flagged(self) -> None:
|
|
411
|
+
self._connect()
|
|
412
|
+
wiql = _response({"workItems": [{"id": 31}]})
|
|
413
|
+
items = _response({"value": [
|
|
414
|
+
{"id": 31, "fields": {"System.Title": "Orphan",
|
|
415
|
+
"System.State": "Ready",
|
|
416
|
+
"System.WorkItemType": "Codee Task"}},
|
|
417
|
+
]})
|
|
418
|
+
|
|
419
|
+
with patch("codee_tasks_azure_devops.provider.requests.post",
|
|
420
|
+
side_effect=[wiql, items]):
|
|
421
|
+
tasks = self.provider.get_tasks(["Ready"])
|
|
422
|
+
|
|
423
|
+
self.assertFalse(tasks[0].is_parent_codee_story)
|
|
424
|
+
|
|
425
|
+
def _tasks_with_parent_type(self, parent_type: str) -> list:
|
|
426
|
+
wiql = _response({"workItems": [{"id": 31}]})
|
|
427
|
+
items = _response({"value": [
|
|
428
|
+
{"id": 31, "fields": {"System.Title": "A child",
|
|
429
|
+
"System.State": "Ready",
|
|
430
|
+
"System.WorkItemType": "Codee Task",
|
|
431
|
+
"System.Parent": 30}},
|
|
432
|
+
]})
|
|
433
|
+
parents = _response({"value": [
|
|
434
|
+
{"id": 30, "fields": {"System.Title": "The parent",
|
|
435
|
+
"System.State": "Active",
|
|
436
|
+
"System.WorkItemType": parent_type}},
|
|
437
|
+
]})
|
|
438
|
+
|
|
439
|
+
with patch("codee_tasks_azure_devops.provider.requests.post",
|
|
440
|
+
side_effect=[wiql, items, parents]):
|
|
441
|
+
return self.provider.get_tasks(["Ready"])
|
|
442
|
+
|
|
443
|
+
def test_empty_result_skips_the_batch_call(self) -> None:
|
|
444
|
+
self._connect()
|
|
445
|
+
with patch("codee_tasks_azure_devops.provider.requests.post",
|
|
446
|
+
side_effect=[_response({"workItems": []})]) as post:
|
|
447
|
+
self.assertEqual(self.provider.get_tasks(["Ready"]), [])
|
|
448
|
+
self.assertEqual(post.call_count, 1)
|
|
449
|
+
|
|
450
|
+
def test_expired_authorization_yields_no_tasks_instead_of_raising(self) -> None:
|
|
451
|
+
# A polling executor must survive a revoked authorization.
|
|
452
|
+
with patch("codee_tasks_azure_devops.provider.requests.post") as post:
|
|
453
|
+
self.assertEqual(self.provider.get_tasks(["Ready"]), [])
|
|
454
|
+
post.assert_not_called()
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def main():
|
|
458
|
+
print("OK")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
if __name__ == "__main__":
|
|
462
|
+
unittest.main()
|
|
File without changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from codee_main_context.context import Settings, TasksProvider
|
|
6
|
+
from codee_tasks_abstract.provider import AbstractTasksProvider, Task
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# The label that marks a JIRA story as Codee-owned. Children of such a story
|
|
10
|
+
# are driven by the story's own agent run, so the executor leaves them alone.
|
|
11
|
+
CODEE_STORY_LABEL = "CodeeStory"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _quote_jql(value: str) -> str:
|
|
15
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
16
|
+
return f'"{escaped}"'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JiraTask(Task):
|
|
20
|
+
"""A JIRA task whose labels are fetched the first time they're read.
|
|
21
|
+
|
|
22
|
+
A parent reference in the search response carries no labels, so resolving
|
|
23
|
+
them eagerly would cost an extra request per tick even when the caller
|
|
24
|
+
never inspects them. Deferring the fetch keeps the common path request-free;
|
|
25
|
+
the result is cached so repeated reads don't re-fetch.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, labels_loader: Callable[[], list[str]] | None = None, **kwargs):
|
|
29
|
+
self._labels_loader = labels_loader
|
|
30
|
+
self._resolved_labels: list[str] | None = None
|
|
31
|
+
super().__init__(**kwargs)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def labels(self) -> list[str]:
|
|
35
|
+
if self._resolved_labels is None:
|
|
36
|
+
if self._raw_labels is not None:
|
|
37
|
+
self._resolved_labels = self._raw_labels
|
|
38
|
+
elif self._labels_loader is not None:
|
|
39
|
+
self._resolved_labels = self._labels_loader()
|
|
40
|
+
else:
|
|
41
|
+
self._resolved_labels = []
|
|
42
|
+
return self._resolved_labels
|
|
43
|
+
|
|
44
|
+
@labels.setter
|
|
45
|
+
def labels(self, value: list[str] | None) -> None:
|
|
46
|
+
# Set by Task.__init__; None means "not present in the response".
|
|
47
|
+
self._raw_labels = value
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_parent_codee_story(self) -> bool:
|
|
51
|
+
"""In JIRA a Codee-owned story is marked with the CodeeStory label."""
|
|
52
|
+
return (self.parent is not None
|
|
53
|
+
and CODEE_STORY_LABEL in self.parent.labels)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class JiraTasksProvider(AbstractTasksProvider):
|
|
57
|
+
"""Fetches AI-owned issues from JIRA and maps them to provider-agnostic Tasks."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, settings: Settings):
|
|
60
|
+
creds = settings.credentials.get(TasksProvider.JIRA.value, {})
|
|
61
|
+
self._base_url = creds.get("base_url")
|
|
62
|
+
self._user_email = creds.get("account_email")
|
|
63
|
+
self._api_token = creds.get("api_token")
|
|
64
|
+
# We poll for tasks assigned to the same account we authenticate as.
|
|
65
|
+
self._assignee_email = self._user_email
|
|
66
|
+
self._project = creds.get("project")
|
|
67
|
+
|
|
68
|
+
def is_configured(self) -> bool:
|
|
69
|
+
return bool(self._user_email and self._api_token)
|
|
70
|
+
|
|
71
|
+
def describe(self) -> str:
|
|
72
|
+
return (f"JIRA {self._base_url} "
|
|
73
|
+
f"(project {self._project}, assignee {self._assignee_email})")
|
|
74
|
+
|
|
75
|
+
def get_tasks(self, statuses: list[str]) -> list[Task]:
|
|
76
|
+
"""Fetch tasks assigned to the target user in the configured states."""
|
|
77
|
+
if not statuses:
|
|
78
|
+
return []
|
|
79
|
+
url = f"{self._base_url}/rest/api/3/search/jql"
|
|
80
|
+
params = {
|
|
81
|
+
"jql": self._build_jql(statuses),
|
|
82
|
+
"fields": "key,summary,status,issuetype,parent,labels,priority",
|
|
83
|
+
"maxResults": 50,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
print("PARAMS", params)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
resp = requests.get(
|
|
90
|
+
url,
|
|
91
|
+
params=params,
|
|
92
|
+
auth=(self._user_email, self._api_token),
|
|
93
|
+
headers={"Accept": "application/json"},
|
|
94
|
+
timeout=30,
|
|
95
|
+
)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
|
|
98
|
+
print("RESP", resp)
|
|
99
|
+
|
|
100
|
+
data = resp.json()
|
|
101
|
+
except requests.RequestException as exc:
|
|
102
|
+
print(f"JIRA API error: {exc}")
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
return [self._to_task(issue) for issue in data.get("issues", [])]
|
|
106
|
+
|
|
107
|
+
def _build_jql(self, statuses: list[str]) -> str:
|
|
108
|
+
"""JQL for AI-owned issues, highest priority first, then oldest."""
|
|
109
|
+
quoted_statuses = ", ".join(
|
|
110
|
+
_quote_jql(status) for status in statuses
|
|
111
|
+
)
|
|
112
|
+
return (
|
|
113
|
+
f'project = {self._project} '
|
|
114
|
+
f'AND assignee = "{self._assignee_email}" '
|
|
115
|
+
f'AND status in ({quoted_statuses}) '
|
|
116
|
+
f'ORDER BY priority DESC, created ASC'
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def _to_task(self, issue: dict) -> Task:
|
|
120
|
+
fields = issue.get("fields", {})
|
|
121
|
+
parent_issue = fields.get("parent")
|
|
122
|
+
return JiraTask(
|
|
123
|
+
key=issue["key"],
|
|
124
|
+
summary=fields.get("summary", ""),
|
|
125
|
+
status=fields.get("status", {}).get("name", ""),
|
|
126
|
+
issue_type=fields.get("issuetype", {}).get("name", ""),
|
|
127
|
+
priority=(fields.get("priority") or {}).get("name", "Unknown"),
|
|
128
|
+
labels=fields.get("labels") or [],
|
|
129
|
+
parent=self._to_parent_task(
|
|
130
|
+
parent_issue) if parent_issue else None,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _to_parent_task(self, parent_issue: dict) -> Task:
|
|
134
|
+
# Labels aren't included for a parent, so defer the fetch until read.
|
|
135
|
+
key = parent_issue["key"]
|
|
136
|
+
fields = parent_issue.get("fields", {})
|
|
137
|
+
return JiraTask(
|
|
138
|
+
key=key,
|
|
139
|
+
summary=fields.get("summary", ""),
|
|
140
|
+
status=fields.get("status", {}).get("name", ""),
|
|
141
|
+
issue_type=fields.get("issuetype", {}).get("name", ""),
|
|
142
|
+
priority=(fields.get("priority") or {}).get("name", "Unknown"),
|
|
143
|
+
labels=fields.get("labels"),
|
|
144
|
+
labels_loader=lambda: self._fetch_issue_labels(key),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _fetch_issue_labels(self, issue_key: str) -> list[str]:
|
|
148
|
+
"""Fetch labels for a single JIRA issue."""
|
|
149
|
+
url = f"{self._base_url}/rest/api/3/issue/{issue_key}"
|
|
150
|
+
try:
|
|
151
|
+
resp = requests.get(
|
|
152
|
+
url,
|
|
153
|
+
params={"fields": "labels"},
|
|
154
|
+
auth=(self._user_email, self._api_token),
|
|
155
|
+
headers={"Accept": "application/json"},
|
|
156
|
+
timeout=30,
|
|
157
|
+
)
|
|
158
|
+
resp.raise_for_status()
|
|
159
|
+
return resp.json().get("fields", {}).get("labels", [])
|
|
160
|
+
except requests.RequestException as exc:
|
|
161
|
+
print(f"[cron_jira] Failed to fetch labels for {issue_key}: {exc}")
|
|
162
|
+
return []
|