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.
Files changed (69) hide show
  1. codee/.gitignore +2 -0
  2. codee/__init__.py +0 -0
  3. codee/admin.py +1672 -0
  4. codee/admin_api.py +61 -0
  5. codee/admin_cli.py +86 -0
  6. codee/admin_service.py +1005 -0
  7. codee/executor.py +381 -0
  8. codee/init_cli.py +82 -0
  9. codee/lib/__init__.py +0 -0
  10. codee/lib/cron_describe.py +33 -0
  11. codee/lib/runs_db.py +195 -0
  12. codee/lib/test_runs_db.py +199 -0
  13. codee/lib/test_trigger_cron_skills.py +485 -0
  14. codee/lib/test_trigger_issue_skills.py +93 -0
  15. codee/lib/trigger_aws_sqs_skills.py +224 -0
  16. codee/lib/trigger_cron_skills.py +364 -0
  17. codee/lib/trigger_email_skills.py +225 -0
  18. codee/lib/trigger_issue_skills.py +107 -0
  19. codee/mail_server.py +45 -0
  20. codee/start_cli.py +98 -0
  21. codee/templates/AGENTS.md +42 -0
  22. codee/templates/CLAUDE.md +1 -0
  23. codee/templates/skills/aws-sqs-alarm-response/SKILL.md +23 -0
  24. codee/templates/skills/cron-research-5xx-errors/SKILL.md +17 -0
  25. codee/templates/skills/story-code-reviewer/SKILL.md +29 -0
  26. codee/templates/skills/story-developer/SKILL.md +26 -0
  27. codee/templates/skills/story-planner/SKILL.md +35 -0
  28. codee/templates/skills/story-planner/assets/readme-template.md +43 -0
  29. codee/templates/skills/story-qa/SKILL.md +28 -0
  30. codee/templates/skills/task-developer/SKILL.md +25 -0
  31. codee/templates/skills/task-qa/SKILL.md +26 -0
  32. codee/test_admin_api.py +64 -0
  33. codee/test_admin_cli.py +63 -0
  34. codee/test_admin_service.py +897 -0
  35. codee/test_executor.py +190 -0
  36. codee/test_init_cli.py +131 -0
  37. codee/test_memory_index.py +31 -0
  38. codee/test_start_cli.py +164 -0
  39. codee/workflow_graph.py +83 -0
  40. codee_admin/__init__.py +1 -0
  41. codee_admin/codee_admin.py +4 -0
  42. codee_agent-0.1.0.dist-info/METADATA +66 -0
  43. codee_agent-0.1.0.dist-info/RECORD +69 -0
  44. codee_agent-0.1.0.dist-info/WHEEL +4 -0
  45. codee_agent-0.1.0.dist-info/entry_points.txt +6 -0
  46. codee_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. codee_agent_abstract/__init__.py +0 -0
  48. codee_agent_abstract/provider.py +56 -0
  49. codee_agent_claude_code/__init__.py +0 -0
  50. codee_agent_claude_code/provider.py +90 -0
  51. codee_agent_github_copilot/__init__.py +0 -0
  52. codee_agent_github_copilot/provider.py +253 -0
  53. codee_agent_github_copilot/test.py +176 -0
  54. codee_database/__init__.py +0 -0
  55. codee_database/database.py +13 -0
  56. codee_database/oauth_tokens.py +148 -0
  57. codee_main_context/__init__.py +0 -0
  58. codee_main_context/context.py +127 -0
  59. codee_main_context/logging.py +111 -0
  60. codee_main_context/test_logging.py +90 -0
  61. codee_tasks_abstract/__init__.py +0 -0
  62. codee_tasks_abstract/provider.py +58 -0
  63. codee_tasks_azure_devops/__init__.py +0 -0
  64. codee_tasks_azure_devops/oauth.py +346 -0
  65. codee_tasks_azure_devops/provider.py +207 -0
  66. codee_tasks_azure_devops/test.py +462 -0
  67. codee_tasks_jira/__init__.py +0 -0
  68. codee_tasks_jira/provider.py +162 -0
  69. codee_tasks_jira/test.py +75 -0
@@ -0,0 +1,58 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass, field
3
+
4
+ from codee_main_context.context import Settings
5
+
6
+
7
+ @dataclass
8
+ class Task:
9
+ """Provider-agnostic representation of a task to be worked on.
10
+
11
+ Models the fields the executor reads out of a JIRA issue, so any provider
12
+ (JIRA, Azure DevOps, ...) can be reduced to the same shape.
13
+ """
14
+
15
+ key: str
16
+ summary: str
17
+ status: str
18
+ issue_type: str
19
+ priority: str
20
+ labels: list[str] = field(default_factory=list)
21
+ parent: "Task | None" = None
22
+
23
+ @property
24
+ def is_parent_codee_story(self) -> bool:
25
+ """Whether this task hangs under a story Codee owns.
26
+
27
+ What marks a story as Codee-owned is provider-specific — a label in
28
+ JIRA, a work item type in Azure DevOps — so each provider answers this
29
+ for its own tasks. The executor only asks the question. A provider that
30
+ has no notion of Codee stories inherits "no parent story".
31
+ """
32
+ return False
33
+
34
+
35
+ class AbstractTasksProvider(ABC):
36
+ """Base class every tasks provider (e.g. JIRA) inherits from.
37
+
38
+ A provider is constructed from the app ``Settings`` and initializes itself
39
+ from its own stored credentials, so the executor never has to know which
40
+ provider it's talking to or what configuration that provider needs.
41
+ """
42
+
43
+ def __init__(self, settings: Settings):
44
+ """Initialize the provider from the stored settings."""
45
+
46
+ @abstractmethod
47
+ def is_configured(self) -> bool:
48
+ """Whether the provider has enough configuration to fetch tasks."""
49
+ ...
50
+
51
+ @abstractmethod
52
+ def get_tasks(self, statuses: list[str]) -> list[Task]:
53
+ """Return agent-owned tasks in the requested statuses, highest priority first."""
54
+ ...
55
+
56
+ def describe(self) -> str:
57
+ """Human-readable one-liner about this provider's config, for logs."""
58
+ return type(self).__name__
File without changes
@@ -0,0 +1,346 @@
1
+ """Entra ID authorization-code flow for Azure DevOps, with automatic refresh.
2
+
3
+ Scope note: Azure DevOps publishes exactly one delegated permission through an
4
+ Entra ID app registration — ``user_impersonation``. There is no read-only
5
+ variant to ask for (the granular ``vso.work`` family only exists in the legacy,
6
+ now-deprecated Azure DevOps OAuth app model). Read-only access is therefore
7
+ enforced on our side: this package only ever issues reads — GETs and WIQL
8
+ queries — and never a create, update, or transition. Narrow it further on the
9
+ Azure DevOps side by authorizing with an account that has Readers access.
10
+
11
+ The flow is confidential-client: the code is exchanged for tokens on the
12
+ backend using the app's client secret, so the secret never reaches the browser.
13
+ PKCE is layered on top even though a secret is used, which keeps an intercepted
14
+ code useless on its own.
15
+ """
16
+ import base64
17
+ import hashlib
18
+ import secrets
19
+ import threading
20
+ from dataclasses import dataclass
21
+ from datetime import datetime, timedelta, timezone
22
+ from urllib.parse import urlencode
23
+
24
+ import requests
25
+ from codee_database import oauth_tokens
26
+ from codee_main_context.context import CodeeMainContext, Settings, TasksProvider
27
+
28
+ # Fixed Entra ID application ID of the Azure DevOps resource. Same value in
29
+ # every tenant; it is what makes a token usable against dev.azure.com.
30
+ AZURE_DEVOPS_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
31
+ SCOPE = f"{AZURE_DEVOPS_RESOURCE_ID}/user_impersonation offline_access"
32
+
33
+ PROVIDER = TasksProvider.AZURE_DEVOPS.value
34
+
35
+ # Path the admin UI serves the callback on. Registered verbatim (behind the
36
+ # admin host) as a redirect URI on the Entra app; Entra matches it exactly.
37
+ CALLBACK_PATH = "/api/oauth/azure-devops/callback"
38
+
39
+ # Refresh this far ahead of the stated expiry, so a token can't lapse midway
40
+ # through a request that already passed the check.
41
+ EXPIRY_MARGIN = timedelta(seconds=120)
42
+
43
+ # Used when no directory is configured: covers any work/school account, which
44
+ # is the only kind Azure DevOps organizations are backed by.
45
+ DEFAULT_TENANT = "organizations"
46
+
47
+ _TIMEOUT = 30
48
+
49
+
50
+ class AzureDevOpsAuthError(RuntimeError):
51
+ """Authorization failed.
52
+
53
+ ``terminal`` separates "this refresh token is dead, the user must consent
54
+ again" from "this attempt failed, the next one may not" — an unreachable
55
+ Entra, a 5xx, a throttle, or a client secret that needs correcting in
56
+ Settings. Only a terminal failure justifies discarding a refresh token that
57
+ might still be worth 90 days of unattended operation.
58
+ """
59
+
60
+ def __init__(self, message: str, terminal: bool = False):
61
+ super().__init__(message)
62
+ self.terminal = terminal
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class OAuthConfig:
67
+ """The Entra app registration, as captured in settings.json."""
68
+
69
+ organization_url: str = ""
70
+ project: str = ""
71
+ tenant_id: str = ""
72
+ client_id: str = ""
73
+ client_secret: str = ""
74
+
75
+ @classmethod
76
+ def from_settings(cls, settings: Settings) -> "OAuthConfig":
77
+ creds = settings.credentials.get(PROVIDER, {})
78
+ return cls(
79
+ organization_url=(creds.get("organization_url") or "").strip().rstrip("/"),
80
+ project=(creds.get("project") or "").strip(),
81
+ tenant_id=(creds.get("tenant_id") or "").strip(),
82
+ client_id=(creds.get("client_id") or "").strip(),
83
+ client_secret=(creds.get("client_secret") or "").strip(),
84
+ )
85
+
86
+ def is_complete(self) -> bool:
87
+ """Whether we have everything needed to run the flow and query tasks."""
88
+ return bool(self.organization_url and self.project
89
+ and self.client_id and self.client_secret)
90
+
91
+ @property
92
+ def tenant(self) -> str:
93
+ return self.tenant_id or DEFAULT_TENANT
94
+
95
+ @property
96
+ def authorize_endpoint(self) -> str:
97
+ return f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/authorize"
98
+
99
+ @property
100
+ def token_endpoint(self) -> str:
101
+ return f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/token"
102
+
103
+
104
+ def new_state() -> str:
105
+ """Opaque value tying the callback back to the request that started it."""
106
+ return secrets.token_urlsafe(32)
107
+
108
+
109
+ def new_code_verifier() -> str:
110
+ return secrets.token_urlsafe(64)
111
+
112
+
113
+ def code_challenge_for(code_verifier: str) -> str:
114
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
115
+ return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
116
+
117
+
118
+ def build_authorization_url(
119
+ config: OAuthConfig,
120
+ redirect_uri: str,
121
+ state: str,
122
+ code_verifier: str,
123
+ ) -> str:
124
+ """The Entra URL to send the browser to for consent."""
125
+ query = urlencode({
126
+ "client_id": config.client_id,
127
+ "response_type": "code",
128
+ "redirect_uri": redirect_uri,
129
+ "response_mode": "query",
130
+ "scope": SCOPE,
131
+ "state": state,
132
+ "code_challenge": code_challenge_for(code_verifier),
133
+ "code_challenge_method": "S256",
134
+ # Force account selection: the admin may well be signed into a personal
135
+ # account that has no access to the organization.
136
+ "prompt": "select_account",
137
+ })
138
+ return f"{config.authorize_endpoint}?{query}"
139
+
140
+
141
+ def exchange_code(
142
+ config: OAuthConfig,
143
+ redirect_uri: str,
144
+ code: str,
145
+ code_verifier: str,
146
+ ) -> dict:
147
+ """Trade an authorization code for access + refresh tokens."""
148
+ return _post_token(config, {
149
+ "client_id": config.client_id,
150
+ "client_secret": config.client_secret,
151
+ "grant_type": "authorization_code",
152
+ "code": code,
153
+ "redirect_uri": redirect_uri,
154
+ "code_verifier": code_verifier,
155
+ "scope": SCOPE,
156
+ })
157
+
158
+
159
+ def refresh_access_token(config: OAuthConfig, refresh_token: str) -> dict:
160
+ """Trade a refresh token for a fresh access token (and usually a new refresh token)."""
161
+ return _post_token(config, {
162
+ "client_id": config.client_id,
163
+ "client_secret": config.client_secret,
164
+ "grant_type": "refresh_token",
165
+ "refresh_token": refresh_token,
166
+ "scope": SCOPE,
167
+ })
168
+
169
+
170
+ def _post_token(config: OAuthConfig, data: dict) -> dict:
171
+ """POST to the token endpoint and normalize the response.
172
+
173
+ Returns ``{access_token, refresh_token, expires_at, scope}`` with
174
+ ``expires_at`` as a UTC ISO timestamp, so callers never have to reason
175
+ about the relative ``expires_in`` they were handed.
176
+ """
177
+ try:
178
+ response = requests.post(
179
+ config.token_endpoint,
180
+ data=data,
181
+ headers={"Accept": "application/json"},
182
+ timeout=_TIMEOUT,
183
+ )
184
+ except requests.RequestException as exc:
185
+ raise AzureDevOpsAuthError(f"Could not reach Entra ID: {exc}") from exc
186
+
187
+ try:
188
+ payload = response.json()
189
+ except ValueError:
190
+ payload = {}
191
+
192
+ if response.status_code >= 400 or "access_token" not in payload:
193
+ raise AzureDevOpsAuthError(
194
+ _describe_token_error(response, payload),
195
+ terminal=_is_terminal_token_error(response, payload))
196
+
197
+ expires_in = payload.get("expires_in")
198
+ try:
199
+ seconds = int(expires_in)
200
+ except (TypeError, ValueError):
201
+ seconds = 3600 # ponytail: no expiry given -> assume the documented default
202
+ return {
203
+ "access_token": payload["access_token"],
204
+ "refresh_token": payload.get("refresh_token"),
205
+ "expires_at": (datetime.now(timezone.utc)
206
+ + timedelta(seconds=seconds)).isoformat(),
207
+ "scope": payload.get("scope", ""),
208
+ }
209
+
210
+
211
+ def _is_terminal_token_error(response, payload: dict) -> bool:
212
+ """Whether the stored refresh token can never be redeemed again.
213
+
214
+ Only ``invalid_grant`` says that — the grant itself is revoked, expired, or
215
+ consent was withdrawn. Everything else is about this attempt: 5xx and 429
216
+ are Entra having a moment, and ``invalid_client`` means the secret in
217
+ Settings needs fixing, which leaves the refresh token perfectly good.
218
+ """
219
+ if response.status_code >= 500 or response.status_code == 429:
220
+ return False
221
+ return payload.get("error") == "invalid_grant"
222
+
223
+
224
+ def _describe_token_error(response, payload: dict) -> str:
225
+ """Entra's error_description is multi-line with correlation IDs; keep line one."""
226
+ description = str(payload.get("error_description") or "").strip()
227
+ if description:
228
+ return description.splitlines()[0]
229
+ error = payload.get("error")
230
+ return str(error) if error else f"Entra ID returned HTTP {response.status_code}"
231
+
232
+
233
+ def fetch_account(access_token: str) -> str:
234
+ """Best-effort display name of the account that authorized, for the UI.
235
+
236
+ A failure here says nothing about the token's usefulness for work items, so
237
+ it degrades to an empty label instead of failing the connection.
238
+ """
239
+ try:
240
+ response = requests.get(
241
+ "https://app.vssps.visualstudio.com/_apis/profile/profiles/me",
242
+ params={"api-version": "7.1"},
243
+ headers={"Authorization": f"Bearer {access_token}",
244
+ "Accept": "application/json"},
245
+ timeout=_TIMEOUT,
246
+ )
247
+ response.raise_for_status()
248
+ profile = response.json()
249
+ except (requests.RequestException, ValueError):
250
+ return ""
251
+ return str(profile.get("emailAddress") or profile.get("displayName") or "")
252
+
253
+
254
+ def is_expired(expires_at: str | None, now: datetime | None = None) -> bool:
255
+ """Whether a stored expiry has passed, or is close enough to count as passed."""
256
+ if not expires_at:
257
+ return True # ponytail: unknown expiry -> refresh rather than send a dud token
258
+ try:
259
+ deadline = datetime.fromisoformat(expires_at)
260
+ except (TypeError, ValueError):
261
+ return True
262
+ if deadline.tzinfo is None:
263
+ deadline = deadline.replace(tzinfo=timezone.utc)
264
+ return deadline - EXPIRY_MARGIN <= (now or datetime.now(timezone.utc))
265
+
266
+
267
+ class AzureDevOpsAuth:
268
+ """Reads the stored tokens and keeps the access token current.
269
+
270
+ One instance per provider instance; the lock keeps the executor's worker
271
+ threads from firing off concurrent refreshes for the same token.
272
+ """
273
+
274
+ def __init__(self, config: OAuthConfig, main_context: CodeeMainContext):
275
+ self._config = config
276
+ self._context = main_context
277
+ self._lock = threading.Lock()
278
+
279
+ def connection(self) -> dict | None:
280
+ """Stored token row, or None when Azure DevOps was never connected."""
281
+ return oauth_tokens.load_tokens(PROVIDER, main_context=self._context)
282
+
283
+ def is_connected(self) -> bool:
284
+ return self.connection() is not None
285
+
286
+ def disconnect(self) -> None:
287
+ oauth_tokens.delete_tokens(PROVIDER, main_context=self._context)
288
+
289
+ def access_token(self) -> str:
290
+ """A usable access token, refreshing first if the stored one is stale.
291
+
292
+ Raises AzureDevOpsAuthError when the user has to reconnect. In that case
293
+ the dead tokens are dropped, so the admin UI reports "not connected"
294
+ instead of showing a connection that can no longer fetch anything.
295
+ """
296
+ with self._lock:
297
+ tokens = self.connection()
298
+ if tokens is None:
299
+ raise AzureDevOpsAuthError(
300
+ "Azure DevOps is not connected. Connect it in Settings.",
301
+ terminal=True)
302
+ if not is_expired(tokens["expires_at"]):
303
+ return tokens["access_token"]
304
+
305
+ refresh_token = tokens.get("refresh_token")
306
+ if not refresh_token:
307
+ self.disconnect()
308
+ raise AzureDevOpsAuthError(
309
+ "The Azure DevOps access token expired and no refresh token "
310
+ "was stored. Reconnect in Settings.", terminal=True)
311
+
312
+ try:
313
+ fresh = refresh_access_token(self._config, refresh_token)
314
+ except AzureDevOpsAuthError as exc:
315
+ if not exc.terminal:
316
+ # Entra was unreachable, throttled, or misconfigured. The
317
+ # refresh token is untouched and the next poll retries it —
318
+ # an outage must not cost the user a manual reconsent.
319
+ raise
320
+ # The grant itself is dead (revoked, or aged past its 90-day
321
+ # window). Retrying it every poll would only hammer Entra.
322
+ self.disconnect()
323
+ raise AzureDevOpsAuthError(
324
+ f"Azure DevOps authorization expired ({exc}). "
325
+ "Reconnect in Settings.", terminal=True) from exc
326
+
327
+ self.store(fresh, account=tokens.get("account") or "",
328
+ fallback_refresh_token=refresh_token)
329
+ return fresh["access_token"]
330
+
331
+ def store(
332
+ self,
333
+ tokens: dict,
334
+ account: str = "",
335
+ fallback_refresh_token: str | None = None,
336
+ ) -> None:
337
+ """Persist a token response. Keeps the previous refresh token if none came back."""
338
+ oauth_tokens.save_tokens(
339
+ PROVIDER,
340
+ access_token=tokens["access_token"],
341
+ refresh_token=tokens.get("refresh_token") or fallback_refresh_token,
342
+ expires_at=tokens.get("expires_at"),
343
+ scope=tokens.get("scope", ""),
344
+ account=account,
345
+ main_context=self._context,
346
+ )
@@ -0,0 +1,207 @@
1
+ """Read-only Azure DevOps tasks provider, authenticated through Entra ID.
2
+
3
+ Every call here is a read: a WIQL query for the ids assigned to the connected
4
+ account, then a batch fetch of those work items. The WIQL endpoint is a POST,
5
+ but it is a query — nothing in this module creates or modifies a work item.
6
+ """
7
+ import requests
8
+ from codee_main_context.context import CodeeMainContext, Settings, data_dir
9
+ from codee_tasks_abstract.provider import AbstractTasksProvider, Task
10
+
11
+ from codee_tasks_azure_devops.oauth import (
12
+ AzureDevOpsAuth, AzureDevOpsAuthError, OAuthConfig)
13
+
14
+ API_VERSION = "7.1"
15
+
16
+ # Work item fields the executor and the issue-trigger matcher read.
17
+ _FIELDS = [
18
+ "System.Id",
19
+ "System.Title",
20
+ "System.State",
21
+ "System.WorkItemType",
22
+ "System.Tags",
23
+ "System.Parent",
24
+ "Microsoft.VSTS.Common.Priority",
25
+ ]
26
+
27
+ # The custom work item types Codee picks up, mapped to the provider-agnostic
28
+ # issue types the executor and the issue-trigger matcher speak. Anything else
29
+ # assigned to the connected account belongs to a human and is left alone.
30
+ _WORK_ITEM_TYPES = {"Codee Task": "Task", "Codee Story": "Story"}
31
+
32
+ # The work item type that marks a story as Codee-owned. Children of such a
33
+ # story are driven by the story's own agent run, so the executor leaves them
34
+ # alone — the Azure DevOps counterpart of JIRA's CodeeStory label.
35
+ CODEE_STORY_WORK_ITEM_TYPE = "Codee Story"
36
+
37
+ # Azure DevOps priority is 1-4 with 1 highest; the executor logs this next to
38
+ # JIRA-style names, so translate rather than print a bare digit.
39
+ _PRIORITY_NAMES = {1: "Highest", 2: "High", 3: "Medium", 4: "Low"}
40
+
41
+ # Ceiling the WIQL query is capped at, matching the JIRA provider's page size.
42
+ _MAX_TASKS = 50
43
+
44
+ # Hard limit of the workitemsbatch endpoint.
45
+ _BATCH_LIMIT = 200
46
+
47
+ _TIMEOUT = 30
48
+
49
+
50
+ def _quote_wiql(value: str) -> str:
51
+ """Single-quoted WIQL literal; a quote inside the value is doubled."""
52
+ return "'" + value.replace("'", "''") + "'"
53
+
54
+
55
+ class AzureDevOpsWorkItem(Task):
56
+ """A Task that remembers the raw Azure DevOps work item type.
57
+
58
+ ``issue_type`` carries the mapped, provider-agnostic name, and that mapping
59
+ is lossy: a "Codee Story" and a plain "Story" both arrive as "Story".
60
+ Keeping the type Azure DevOps actually reported is what lets
61
+ ``is_parent_codee_story`` tell one parent from the other.
62
+ """
63
+
64
+ def __init__(self, work_item_type: str = "", **kwargs):
65
+ self.work_item_type = work_item_type
66
+ super().__init__(**kwargs)
67
+
68
+ @property
69
+ def is_parent_codee_story(self) -> bool:
70
+ """In Azure DevOps a Codee-owned story is a "Codee Story" work item."""
71
+ parent = self.parent
72
+ return (isinstance(parent, AzureDevOpsWorkItem)
73
+ and parent.work_item_type == CODEE_STORY_WORK_ITEM_TYPE)
74
+
75
+
76
+ class AzureDevOpsTasksProvider(AbstractTasksProvider):
77
+ """Fetches work items assigned to the connected account as provider-agnostic Tasks."""
78
+
79
+ def __init__(self, settings: Settings, main_context: CodeeMainContext | None = None):
80
+ self._config = OAuthConfig.from_settings(settings)
81
+ # The executor constructs providers with settings alone, so fall back to
82
+ # the default data directory to reach the token store.
83
+ context = main_context or CodeeMainContext(data_dir=data_dir())
84
+ self._auth = AzureDevOpsAuth(self._config, context)
85
+
86
+ def is_configured(self) -> bool:
87
+ """Configured means the app details are filled in *and* OAuth completed."""
88
+ return self._config.is_complete() and self._auth.is_connected()
89
+
90
+ def describe(self) -> str:
91
+ connection = self._auth.connection() or {}
92
+ account = connection.get("account") or "connected account"
93
+ return (f"Azure DevOps {self._config.organization_url} "
94
+ f"(project {self._config.project}, assignee {account})")
95
+
96
+ def get_tasks(self, statuses: list[str]) -> list[Task]:
97
+ """Fetch work items assigned to the connected account in the given states."""
98
+ if not statuses:
99
+ return []
100
+ try:
101
+ token = self._auth.access_token()
102
+ except AzureDevOpsAuthError as exc:
103
+ print(f"Azure DevOps auth error: {exc}")
104
+ return []
105
+
106
+ try:
107
+ ids = self._query_work_item_ids(token, statuses)
108
+ if not ids:
109
+ return []
110
+ items = self._fetch_work_items(token, ids)
111
+ parents = self._fetch_parents(token, items)
112
+ except requests.RequestException as exc:
113
+ print(f"Azure DevOps API error: {exc}")
114
+ return []
115
+
116
+ # The batch endpoint doesn't preserve the WIQL ordering, so restore the
117
+ # priority-then-age order the query asked for.
118
+ by_id = {item["id"]: item for item in items}
119
+ return [self._to_task(by_id[item_id], parents)
120
+ for item_id in ids if item_id in by_id]
121
+
122
+ def _query_work_item_ids(self, token: str, statuses: list[str]) -> list[int]:
123
+ response = requests.post(
124
+ f"{self._config.organization_url}/{self._config.project}/_apis/wit/wiql",
125
+ params={"api-version": API_VERSION, "$top": _MAX_TASKS},
126
+ json={"query": self._build_wiql(statuses)},
127
+ headers=self._headers(token),
128
+ timeout=_TIMEOUT,
129
+ )
130
+ response.raise_for_status()
131
+ work_items = response.json().get("workItems") or []
132
+ return [item["id"] for item in work_items][:_BATCH_LIMIT]
133
+
134
+ def _build_wiql(self, statuses: list[str]) -> str:
135
+ """WIQL for Codee work items owned by the connected account, highest priority first."""
136
+ quoted_statuses = ", ".join(_quote_wiql(status) for status in statuses)
137
+ quoted_types = ", ".join(_quote_wiql(item_type)
138
+ for item_type in _WORK_ITEM_TYPES)
139
+ return (
140
+ "SELECT [System.Id] FROM WorkItems "
141
+ "WHERE [System.AssignedTo] = @Me "
142
+ f"AND [System.WorkItemType] IN ({quoted_types}) "
143
+ f"AND [System.State] IN ({quoted_statuses}) "
144
+ "ORDER BY [Microsoft.VSTS.Common.Priority] ASC, [System.CreatedDate] ASC"
145
+ )
146
+
147
+ def _fetch_work_items(self, token: str, ids: list[int]) -> list[dict]:
148
+ """Batch-fetch the requested work items. Organization-scoped, as the API requires."""
149
+ if not ids:
150
+ return []
151
+ response = requests.post(
152
+ f"{self._config.organization_url}/_apis/wit/workitemsbatch",
153
+ params={"api-version": API_VERSION},
154
+ json={"ids": ids[:_BATCH_LIMIT], "fields": _FIELDS},
155
+ headers=self._headers(token),
156
+ timeout=_TIMEOUT,
157
+ )
158
+ response.raise_for_status()
159
+ return response.json().get("value") or []
160
+
161
+ def _fetch_parents(self, token: str, items: list[dict]) -> dict[int, dict]:
162
+ """Resolve every referenced parent in one extra call.
163
+
164
+ Parents are fetched eagerly, unlike JIRA's deferred labels: here the
165
+ parent is a plain id, so there is no cheaper partial representation to
166
+ start from, and one batch call covers the whole page of tasks.
167
+ """
168
+ parent_ids = {
169
+ parent_id for parent_id in
170
+ (item.get("fields", {}).get("System.Parent") for item in items)
171
+ if parent_id
172
+ }
173
+ if not parent_ids:
174
+ return {}
175
+ parents = self._fetch_work_items(token, sorted(parent_ids))
176
+ return {parent["id"]: parent for parent in parents}
177
+
178
+ def _headers(self, token: str) -> dict[str, str]:
179
+ return {"Authorization": f"Bearer {token}",
180
+ "Accept": "application/json"}
181
+
182
+ def _to_task(self, item: dict, parents: dict[int, dict]) -> Task:
183
+ fields = item.get("fields", {})
184
+ parent = parents.get(fields.get("System.Parent"))
185
+ work_item_type = fields.get("System.WorkItemType", "")
186
+ return AzureDevOpsWorkItem(
187
+ work_item_type=work_item_type,
188
+ key=str(item["id"]),
189
+ summary=fields.get("System.Title", ""),
190
+ status=fields.get("System.State", ""),
191
+ # Parents aren't type-filtered by the query, so an unmapped type
192
+ # (a plain "User Story" above a Codee Task) passes through as-is.
193
+ issue_type=_WORK_ITEM_TYPES.get(work_item_type, work_item_type),
194
+ priority=_PRIORITY_NAMES.get(
195
+ fields.get("Microsoft.VSTS.Common.Priority"), "Unknown"),
196
+ labels=_split_tags(fields.get("System.Tags")),
197
+ # A parent's own parent is left unresolved: the executor only ever
198
+ # looks one level up, and chasing the chain would cost a call per level.
199
+ parent=self._to_task(parent, {}) if parent else None,
200
+ )
201
+
202
+
203
+ def _split_tags(tags: str | None) -> list[str]:
204
+ """Azure DevOps returns tags as one '; '-joined string."""
205
+ if not tags:
206
+ return []
207
+ return [tag.strip() for tag in tags.split(";") if tag.strip()]