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,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()]
|