agent-memory-brain 0.2.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.
- agent_memory_brain-0.2.0.dist-info/METADATA +98 -0
- agent_memory_brain-0.2.0.dist-info/RECORD +54 -0
- agent_memory_brain-0.2.0.dist-info/WHEEL +4 -0
- agent_memory_brain-0.2.0.dist-info/entry_points.txt +3 -0
- agent_memory_brain-0.2.0.dist-info/licenses/LICENSE +67 -0
- devmem/__init__.py +3 -0
- devmem/ado.py +433 -0
- devmem/agent_colors.py +74 -0
- devmem/cli.py +500 -0
- devmem/db.py +1069 -0
- devmem/dispatch.py +478 -0
- devmem/embed.py +111 -0
- devmem/filelock.py +53 -0
- devmem/git_sync.py +261 -0
- devmem/guard.py +214 -0
- devmem/hook_log.py +49 -0
- devmem/ingest/__init__.py +1 -0
- devmem/ingest/claude.py +87 -0
- devmem/ingest/codex.py +175 -0
- devmem/ingest/runner.py +318 -0
- devmem/ingest/subagents.py +250 -0
- devmem/maintenance.py +801 -0
- devmem/mcp_server.py +542 -0
- devmem/messages.py +684 -0
- devmem/panel/__init__.py +1 -0
- devmem/panel/app.py +971 -0
- devmem/panel/state.py +358 -0
- devmem/panel/static/base.css +27 -0
- devmem/panel/static/brain.css +473 -0
- devmem/panel/static/brain.js +1118 -0
- devmem/panel/static/brain3d.js +600 -0
- devmem/panel/static/chat.js +18 -0
- devmem/panel/static/common.js +208 -0
- devmem/panel/static/site.css +508 -0
- devmem/panel/static/tokens.css +19 -0
- devmem/panel/templates/ado.html +62 -0
- devmem/panel/templates/agents.html +35 -0
- devmem/panel/templates/audit.html +19 -0
- devmem/panel/templates/base.html +42 -0
- devmem/panel/templates/brain.html +101 -0
- devmem/panel/templates/maintenance.html +32 -0
- devmem/panel/templates/memory.html +71 -0
- devmem/panel/templates/message_thread.html +49 -0
- devmem/panel/templates/messages.html +8 -0
- devmem/panel/templates/overview.html +109 -0
- devmem/panel/templates/project.html +48 -0
- devmem/panel/templates/projects.html +21 -0
- devmem/panel/templates/session.html +49 -0
- devmem/panel/templates/sessions.html +67 -0
- devmem/panel/templates/subagent.html +27 -0
- devmem/procs.py +10 -0
- devmem/projects.py +17 -0
- devmem/secrets.py +9 -0
- devmem/skills.py +105 -0
devmem/ado.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Strictly read-only Azure DevOps data for the local panel.
|
|
2
|
+
|
|
3
|
+
This module deliberately has a very small network surface. Keep every ADO
|
|
4
|
+
request in :meth:`AdoClient._request`; that makes the allowlist auditable and
|
|
5
|
+
prevents a future caller from accidentally gaining write access.
|
|
6
|
+
The allowlist is rebuilt for every request from the organization/project that
|
|
7
|
+
the refresh resolved once; the host, methods, and endpoint shapes remain fixed.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import socket
|
|
17
|
+
import traceback
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
from urllib.error import HTTPError, URLError
|
|
22
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
23
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
TOKEN_PATH = Path.home() / ".devmem" / "ado_token"
|
|
27
|
+
CONFIG_PATH = Path.home() / ".devmem" / "ado.json"
|
|
28
|
+
TIMEOUT_S = 15
|
|
29
|
+
API_VERSION = "7.1"
|
|
30
|
+
_ORGANIZATION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,49}$")
|
|
31
|
+
_PROJECT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AdoError(RuntimeError):
|
|
36
|
+
"""A safe-to-display ADO failure (its message never includes credentials)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AdoForbidden(AdoError):
|
|
40
|
+
"""Raised without network I/O for a method, origin, path, or body outside the allowlist."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
44
|
+
"""Redirects could escape an otherwise safe URL; reject every one of them."""
|
|
45
|
+
|
|
46
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_OPENER = build_opener(_NoRedirect())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _utcnow() -> datetime:
|
|
54
|
+
return datetime.now(UTC)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _iso_now() -> str:
|
|
58
|
+
return _utcnow().isoformat().replace("+00:00", "Z")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _age_label(seconds: int | None) -> str:
|
|
62
|
+
if seconds is None:
|
|
63
|
+
return "age unknown"
|
|
64
|
+
if seconds < 3600:
|
|
65
|
+
return f"{max(1, seconds // 60)}m old"
|
|
66
|
+
if seconds < 86400:
|
|
67
|
+
return f"{seconds // 3600}h old"
|
|
68
|
+
return f"{seconds // 86400}d old"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_token_status() -> tuple[str | None, str | None]:
|
|
72
|
+
"""Load the local token at runtime without exposing its contents."""
|
|
73
|
+
try:
|
|
74
|
+
value = TOKEN_PATH.read_text(encoding="utf-8").strip()
|
|
75
|
+
except FileNotFoundError:
|
|
76
|
+
return None, None
|
|
77
|
+
except UnicodeDecodeError:
|
|
78
|
+
return None, "ado_token: not UTF-8 text"
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
return None, f"ado_token: cannot be read ({type(exc).__name__})"
|
|
81
|
+
return value or None, None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read_token() -> str | None:
|
|
85
|
+
"""Default token loader: the token only, without the read-error reason."""
|
|
86
|
+
return _read_token_status()[0]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _validate_config(value: object, token: str | None = None, sources: dict[str, str] | None = None) -> tuple[tuple[str, str] | None, str | None]:
|
|
90
|
+
"""Validate a pair without ever including its values in an error."""
|
|
91
|
+
if not isinstance(value, tuple) or len(value) != 2:
|
|
92
|
+
return None, "config must be an organization/project tuple"
|
|
93
|
+
organization, project = value
|
|
94
|
+
for name, item, pattern, rule in (
|
|
95
|
+
("organization", organization, _ORGANIZATION_RE, "letters, digits, hyphens; must start with a letter or digit; max 50"),
|
|
96
|
+
("project", project, _PROJECT_RE, "letters, digits, spaces, . _ -; must start with a letter or digit; max 64"),
|
|
97
|
+
):
|
|
98
|
+
prefix = f"{(sources or {}).get(name, '')} " if (sources or {}).get(name) else ""
|
|
99
|
+
if not isinstance(item, str):
|
|
100
|
+
return None, f"{prefix}{name} must be a string"
|
|
101
|
+
if token is not None and item == token:
|
|
102
|
+
return None, f"{prefix}{name} must not be the token"
|
|
103
|
+
if not pattern.fullmatch(item):
|
|
104
|
+
return None, f"{prefix}{name} is not a valid ADO {name} name ({rule})"
|
|
105
|
+
return (organization, project), None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read_config() -> tuple[tuple[str, str] | None, str | None]:
|
|
109
|
+
"""Read env fields with file fallback, including an expandable path override and safe reasons.
|
|
110
|
+
|
|
111
|
+
Each non-empty env field overrides only that field. Missing fields fall back
|
|
112
|
+
to ``DEVMEM_ADO_CONFIG`` (or the default file); all malformed input is
|
|
113
|
+
reported as a reason without exposing configured values.
|
|
114
|
+
"""
|
|
115
|
+
organization = os.environ.get("DEVMEM_ADO_ORG") or None
|
|
116
|
+
project = os.environ.get("DEVMEM_ADO_PROJECT") or None
|
|
117
|
+
override = os.environ.get("DEVMEM_ADO_CONFIG") or None
|
|
118
|
+
path = Path(override).expanduser() if override else CONFIG_PATH
|
|
119
|
+
if override and not path.exists():
|
|
120
|
+
return None, "config file not found (DEVMEM_ADO_CONFIG)"
|
|
121
|
+
value: object = {}
|
|
122
|
+
if organization is None or project is None:
|
|
123
|
+
try:
|
|
124
|
+
text = path.read_text(encoding="utf-8")
|
|
125
|
+
value = json.loads(text)
|
|
126
|
+
except FileNotFoundError:
|
|
127
|
+
if override:
|
|
128
|
+
return None, "config file not found (DEVMEM_ADO_CONFIG)"
|
|
129
|
+
if organization is None and project is None:
|
|
130
|
+
return None, None
|
|
131
|
+
missing = "DEVMEM_ADO_ORG" if organization is None else "DEVMEM_ADO_PROJECT"
|
|
132
|
+
return None, f"{missing} is not set and no config file exists"
|
|
133
|
+
except json.JSONDecodeError as exc:
|
|
134
|
+
return None, f"config file: invalid JSON ({exc.msg} at line {exc.lineno} column {exc.colno})"
|
|
135
|
+
except (OSError, UnicodeDecodeError, ValueError, RecursionError) as exc:
|
|
136
|
+
return None, f"config file: cannot be read ({type(exc).__name__})"
|
|
137
|
+
if not isinstance(value, dict):
|
|
138
|
+
return None, "config file: not a JSON object"
|
|
139
|
+
unknown = set(value) - {"organization", "project"}
|
|
140
|
+
if unknown:
|
|
141
|
+
return None, f"config file: {len(unknown)} unknown key(s) (allowed: organization, project)"
|
|
142
|
+
if organization is None:
|
|
143
|
+
if "organization" not in value:
|
|
144
|
+
return None, "config file: missing key organization"
|
|
145
|
+
organization = value["organization"]
|
|
146
|
+
if project is None:
|
|
147
|
+
if "project" not in value:
|
|
148
|
+
return None, "config file: missing key project"
|
|
149
|
+
project = value["project"]
|
|
150
|
+
sources = {
|
|
151
|
+
"organization": "DEVMEM_ADO_ORG" if os.environ.get("DEVMEM_ADO_ORG") else "config file:",
|
|
152
|
+
"project": "DEVMEM_ADO_PROJECT" if os.environ.get("DEVMEM_ADO_PROJECT") else "config file:",
|
|
153
|
+
}
|
|
154
|
+
return _validate_config((organization, project), sources=sources)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def empty_snapshot(configured: bool = False, config_error: str | None = None, missing: list[str] | None = None,
|
|
158
|
+
token_error: str | None = None) -> dict[str, Any]:
|
|
159
|
+
"""The stable public shape used before the first successful refresh too."""
|
|
160
|
+
return {
|
|
161
|
+
"configured": configured,
|
|
162
|
+
"config_error": config_error,
|
|
163
|
+
"token_error": token_error,
|
|
164
|
+
"missing": missing or [],
|
|
165
|
+
"scope": None,
|
|
166
|
+
"pending": False,
|
|
167
|
+
"refreshed_at": None,
|
|
168
|
+
"stale": False,
|
|
169
|
+
"error": None,
|
|
170
|
+
"me": None,
|
|
171
|
+
"project_active_count": 0,
|
|
172
|
+
"to_review": [],
|
|
173
|
+
"my_changes_requested": [],
|
|
174
|
+
"my_approved_not_completed": [],
|
|
175
|
+
"my_waiting_on_others": [],
|
|
176
|
+
"open_tasks": [],
|
|
177
|
+
"open_tasks_by_state": {},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class AdoClient:
|
|
182
|
+
"""Read-only client for exactly the panel's allowed ADO endpoints."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, token_loader: Callable[[], str | None] = _read_token,
|
|
185
|
+
config_loader: Callable[[], object] = _read_config):
|
|
186
|
+
self._token_loader = token_loader
|
|
187
|
+
self._config_loader = config_loader
|
|
188
|
+
self._config_loader_failures: set[type[BaseException]] = set()
|
|
189
|
+
|
|
190
|
+
def _token(self) -> tuple[str | None, str | None]:
|
|
191
|
+
if self._token_loader is _read_token:
|
|
192
|
+
return _read_token_status()
|
|
193
|
+
return self._token_loader(), None
|
|
194
|
+
|
|
195
|
+
def _config(self, token: str | None = None) -> tuple[tuple[str, str] | None, str | None]:
|
|
196
|
+
"""Re-validate loader output; loader errors are logged by type and disable ADO.
|
|
197
|
+
|
|
198
|
+
Loaders return only the normal ``(config, reason)`` result.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
value = self._config_loader()
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
if type(exc) not in self._config_loader_failures:
|
|
204
|
+
# Frames only: the exception message could contain a config value.
|
|
205
|
+
log.error("ADO config loader failed (%s)\n%s", type(exc).__name__,
|
|
206
|
+
"".join(traceback.format_tb(exc.__traceback__)).rstrip())
|
|
207
|
+
self._config_loader_failures.add(type(exc))
|
|
208
|
+
return None, f"config loader error ({type(exc).__name__})"
|
|
209
|
+
if (isinstance(value, tuple) and len(value) == 2 and
|
|
210
|
+
(value[0] is None or isinstance(value[0], tuple))):
|
|
211
|
+
config, reason = value
|
|
212
|
+
if config is None:
|
|
213
|
+
# (None, None) is the normal "nothing configured" result, not an error.
|
|
214
|
+
return None, reason if reason is None or isinstance(reason, str) else "config loader returned an invalid result"
|
|
215
|
+
validated, validation_reason = _validate_config(config, token)
|
|
216
|
+
if reason is not None and not isinstance(reason, str):
|
|
217
|
+
return None, "config loader returned an invalid result"
|
|
218
|
+
return validated, validation_reason or reason
|
|
219
|
+
return None, "config loader returned an invalid result"
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _wiql(project: str) -> str:
|
|
223
|
+
# Defence in depth: validation already rejects apostrophes.
|
|
224
|
+
project = project.replace("'", "''")
|
|
225
|
+
return ("SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = '" + project + "' "
|
|
226
|
+
"AND [System.AssignedTo] = @Me AND [System.State] NOT IN "
|
|
227
|
+
"('Closed','Done','Removed') ORDER BY [System.ChangedDate] DESC")
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _paths(config: tuple[str, str]) -> tuple[frozenset[str], frozenset[str]]:
|
|
231
|
+
organization, project = config
|
|
232
|
+
prefix = f"/{organization}/{quote(project, safe='')}"
|
|
233
|
+
return (frozenset({f"/{organization}/_apis/connectionData", f"{prefix}/_apis/wit/workitems",
|
|
234
|
+
f"{prefix}/_apis/git/pullrequests"}),
|
|
235
|
+
frozenset({f"{prefix}/_apis/wit/wiql"}))
|
|
236
|
+
|
|
237
|
+
def configured(self) -> bool:
|
|
238
|
+
"""Whether both a token and valid organization/project are present, without exposing them."""
|
|
239
|
+
token, _ = self._token()
|
|
240
|
+
config, _ = self._config(token)
|
|
241
|
+
return bool(token and config)
|
|
242
|
+
|
|
243
|
+
def _request(self, method: str, url: str, body: bytes | None = None, *, config: tuple[str, str] | None = None) -> dict[str, Any]:
|
|
244
|
+
"""Validate first, then make one authenticated request with no redirects."""
|
|
245
|
+
token, _ = self._token()
|
|
246
|
+
if config is None:
|
|
247
|
+
config, reason = self._config(token)
|
|
248
|
+
else:
|
|
249
|
+
reason = None
|
|
250
|
+
if not token or config is None:
|
|
251
|
+
raise AdoError(reason or "ADO not configured (missing token or organization/project)")
|
|
252
|
+
method = method.upper()
|
|
253
|
+
parts = urlsplit(url)
|
|
254
|
+
# netloc rather than hostname rejects userinfo and alternate ports as well.
|
|
255
|
+
valid_origin = parts.scheme == "https" and parts.netloc == "dev.azure.com"
|
|
256
|
+
get_paths, post_paths = self._paths(config)
|
|
257
|
+
valid_path = ((method == "GET" and parts.path in get_paths)
|
|
258
|
+
or (method == "POST" and parts.path in post_paths))
|
|
259
|
+
if not valid_origin or not valid_path:
|
|
260
|
+
raise AdoForbidden("ADO request is outside the read-only allowlist")
|
|
261
|
+
if method == "GET" and body is not None:
|
|
262
|
+
raise AdoForbidden("ADO GET requests cannot have a body")
|
|
263
|
+
if method == "POST" and (parts.path not in post_paths or body is None):
|
|
264
|
+
raise AdoForbidden("ADO POST is limited to the WIQL query")
|
|
265
|
+
if method == "POST":
|
|
266
|
+
try:
|
|
267
|
+
is_wiql = json.loads(body.decode("utf-8")) == {"query": self._wiql(config[1])}
|
|
268
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
269
|
+
is_wiql = False
|
|
270
|
+
if not is_wiql:
|
|
271
|
+
raise AdoForbidden("ADO POST is limited to the fixed WIQL query")
|
|
272
|
+
|
|
273
|
+
authorization = base64.b64encode((":" + token).encode("utf-8")).decode("ascii")
|
|
274
|
+
headers = {"Authorization": "Basic " + authorization, "Accept": "application/json"}
|
|
275
|
+
if body is not None:
|
|
276
|
+
headers["Content-Type"] = "application/json"
|
|
277
|
+
request = Request(url, data=body, headers=headers, method=method)
|
|
278
|
+
try:
|
|
279
|
+
with _OPENER.open(request, timeout=TIMEOUT_S) as response:
|
|
280
|
+
status = getattr(response, "status", None)
|
|
281
|
+
getcode = getattr(response, "getcode", None)
|
|
282
|
+
if status is None and getcode:
|
|
283
|
+
status = getcode()
|
|
284
|
+
content_type = getattr(response, "headers", {}).get("Content-Type", "")
|
|
285
|
+
if status == 203 and "html" in content_type.lower():
|
|
286
|
+
raise AdoError("HTTP 203 — token expired or missing scope?")
|
|
287
|
+
return json.loads(response.read().decode("utf-8"))
|
|
288
|
+
except HTTPError as exc:
|
|
289
|
+
message = f"HTTP {exc.code}"
|
|
290
|
+
if exc.code == 401:
|
|
291
|
+
message += " — token expired, missing scope, or wrong organization/project?"
|
|
292
|
+
elif exc.code in (203, 403):
|
|
293
|
+
message += " — token expired or missing scope?"
|
|
294
|
+
if exc.code in (302, 404):
|
|
295
|
+
message += " — check organization/project in the ADO config"
|
|
296
|
+
raise AdoError(message) from None
|
|
297
|
+
except (TimeoutError, socket.timeout):
|
|
298
|
+
raise AdoError("timeout") from None
|
|
299
|
+
except URLError as exc:
|
|
300
|
+
if isinstance(exc.reason, (TimeoutError, socket.timeout)):
|
|
301
|
+
raise AdoError("timeout") from None
|
|
302
|
+
raise AdoError(f"network error ({type(exc).__name__})") from None
|
|
303
|
+
except OSError as exc:
|
|
304
|
+
raise AdoError(f"network error ({type(exc).__name__})") from None
|
|
305
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
306
|
+
raise AdoError("invalid JSON") from None
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def _url(path: str, **query: Any) -> str:
|
|
310
|
+
# Paths already start with /<organization>, as the allowlist expects.
|
|
311
|
+
query.setdefault("api-version", API_VERSION)
|
|
312
|
+
return "https://dev.azure.com" + path + "?" + urlencode(query, doseq=True)
|
|
313
|
+
|
|
314
|
+
def _get(self, path: str, *, config: tuple[str, str], **query: Any) -> dict[str, Any]:
|
|
315
|
+
return self._request("GET", self._url(path, **query), config=config)
|
|
316
|
+
|
|
317
|
+
def _post_wiql(self, config: tuple[str, str]) -> dict[str, Any]:
|
|
318
|
+
organization, project = config
|
|
319
|
+
body = json.dumps({"query": self._wiql(project)}, separators=(",", ":")).encode("utf-8")
|
|
320
|
+
path = f"/{organization}/{quote(project, safe='')}/_apis/wit/wiql"
|
|
321
|
+
return self._request("POST", self._url(path), body, config=config)
|
|
322
|
+
|
|
323
|
+
def snapshot(self) -> dict[str, Any]:
|
|
324
|
+
"""Fetch and classify ADO data, or report disabled without network I/O."""
|
|
325
|
+
token, token_error = self._token()
|
|
326
|
+
config, reason = self._config(token)
|
|
327
|
+
# A specific token/config error already says what to fix; "missing" is only for absent ones.
|
|
328
|
+
missing = ([] if token or token_error else ["token"]) + ([] if config or reason else ["config"])
|
|
329
|
+
if not token or config is None:
|
|
330
|
+
return empty_snapshot(config_error=reason, token_error=token_error, missing=missing)
|
|
331
|
+
|
|
332
|
+
organization, project = config
|
|
333
|
+
project_path = quote(project, safe="")
|
|
334
|
+
|
|
335
|
+
connection = self._get(f"/{organization}/_apis/connectionData", config=config, **{"api-version": API_VERSION + "-preview"}) # preview-only API
|
|
336
|
+
person = connection.get("authenticatedUser") or {}
|
|
337
|
+
my_id = person.get("id")
|
|
338
|
+
if not my_id:
|
|
339
|
+
raise AdoError("Azure DevOps identity was unavailable")
|
|
340
|
+
me = {"id": my_id, "name": person.get("providerDisplayName") or connection.get("providerDisplayName")
|
|
341
|
+
or person.get("displayName") or "Me"}
|
|
342
|
+
|
|
343
|
+
wiql = self._post_wiql(config)
|
|
344
|
+
ids = [str(row["id"]) for row in wiql.get("workItems", []) if row.get("id") is not None]
|
|
345
|
+
work_items: list[dict[str, Any]] = []
|
|
346
|
+
fields = "System.Id,System.Title,System.State,System.WorkItemType,System.ChangedDate"
|
|
347
|
+
for start in range(0, len(ids), 200):
|
|
348
|
+
data = self._get(f"/{organization}/{project_path}/_apis/wit/workitems", config=config, ids=",".join(ids[start:start + 200]), fields=fields)
|
|
349
|
+
work_items.extend(self._work_item(item, organization, project) for item in data.get("value", []))
|
|
350
|
+
|
|
351
|
+
prs = self._get(f"/{organization}/{project_path}/_apis/git/pullrequests", config=config, **{"searchCriteria.status": "active", "$top": 500})
|
|
352
|
+
classified = self._classify_prs(prs.get("value", []), my_id, organization, project)
|
|
353
|
+
tasks_by_state: dict[str, list[dict[str, Any]]] = {}
|
|
354
|
+
for item in work_items:
|
|
355
|
+
tasks_by_state.setdefault(item["state"] or "Unknown", []).append(item)
|
|
356
|
+
return {
|
|
357
|
+
"configured": True,
|
|
358
|
+
"config_error": None,
|
|
359
|
+
"token_error": None,
|
|
360
|
+
"missing": [],
|
|
361
|
+
"scope": f"{organization}/{project}",
|
|
362
|
+
"refreshed_at": _iso_now(),
|
|
363
|
+
"stale": False,
|
|
364
|
+
"error": None,
|
|
365
|
+
"me": me,
|
|
366
|
+
"project_active_count": len(prs.get("value", [])),
|
|
367
|
+
**classified,
|
|
368
|
+
"open_tasks": work_items,
|
|
369
|
+
"open_tasks_by_state": tasks_by_state,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
def _work_item(self, item: dict[str, Any], organization: str, project: str) -> dict[str, Any]:
|
|
373
|
+
fields = item.get("fields") or {}
|
|
374
|
+
work_id = item.get("id") or fields.get("System.Id")
|
|
375
|
+
return {
|
|
376
|
+
"id": work_id,
|
|
377
|
+
"title": fields.get("System.Title") or "Untitled work item",
|
|
378
|
+
"type": fields.get("System.WorkItemType") or "",
|
|
379
|
+
"state": fields.get("System.State") or "",
|
|
380
|
+
"changed_date": fields.get("System.ChangedDate"),
|
|
381
|
+
"url": f"https://dev.azure.com/{organization}/{quote(project, safe='')}/_workitems/edit/{work_id}",
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
def _pr(self, raw: dict[str, Any], organization: str, project: str) -> dict[str, Any]:
|
|
385
|
+
pr_id = raw.get("pullRequestId")
|
|
386
|
+
repo = (raw.get("repository") or {}).get("name") or ""
|
|
387
|
+
reviewers = [{
|
|
388
|
+
"id": reviewer.get("id"),
|
|
389
|
+
"name": reviewer.get("displayName") or reviewer.get("uniqueName") or "Unknown reviewer",
|
|
390
|
+
"vote": int(reviewer.get("vote") or 0),
|
|
391
|
+
} for reviewer in raw.get("reviewers", [])]
|
|
392
|
+
created = raw.get("creationDate")
|
|
393
|
+
age_seconds = None
|
|
394
|
+
if isinstance(created, str):
|
|
395
|
+
try:
|
|
396
|
+
parsed = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
|
397
|
+
age_seconds = max(0, int((_utcnow() - parsed).total_seconds()))
|
|
398
|
+
except ValueError:
|
|
399
|
+
pass
|
|
400
|
+
return {
|
|
401
|
+
"id": pr_id,
|
|
402
|
+
"title": raw.get("title") or "Untitled pull request",
|
|
403
|
+
"repo": repo,
|
|
404
|
+
"url": f"https://dev.azure.com/{organization}/{quote(project, safe='')}/_git/{quote(repo, safe='')}/pullrequest/{pr_id}",
|
|
405
|
+
"created_date": created,
|
|
406
|
+
"age_seconds": age_seconds,
|
|
407
|
+
"age_label": _age_label(age_seconds),
|
|
408
|
+
"reviewers": reviewers,
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
def _classify_prs(self, values: list[dict[str, Any]], my_id: str, organization: str, project: str) -> dict[str, list[dict[str, Any]]]:
|
|
412
|
+
buckets = {
|
|
413
|
+
"to_review": [], "my_changes_requested": [], "my_approved_not_completed": [], "my_waiting_on_others": [],
|
|
414
|
+
}
|
|
415
|
+
for raw in values:
|
|
416
|
+
author_id = (raw.get("createdBy") or {}).get("id")
|
|
417
|
+
reviewers = raw.get("reviewers") or []
|
|
418
|
+
mine = author_id == my_id
|
|
419
|
+
my_review = next((reviewer for reviewer in reviewers if reviewer.get("id") == my_id), None)
|
|
420
|
+
pr = self._pr(raw, organization, project)
|
|
421
|
+
if not mine and my_review is not None and int(my_review.get("vote") or 0) == 0:
|
|
422
|
+
buckets["to_review"].append(pr)
|
|
423
|
+
if not mine:
|
|
424
|
+
continue
|
|
425
|
+
votes = [int(reviewer.get("vote") or 0) for reviewer in reviewers]
|
|
426
|
+
if any(vote < 0 for vote in votes):
|
|
427
|
+
buckets["my_changes_requested"].append(pr)
|
|
428
|
+
elif any(vote >= 5 for vote in votes):
|
|
429
|
+
buckets["my_approved_not_completed"].append(pr)
|
|
430
|
+
else:
|
|
431
|
+
pr["pending_reviewers"] = [reviewer["name"] for reviewer in pr["reviewers"] if reviewer["vote"] == 0]
|
|
432
|
+
buckets["my_waiting_on_others"].append(pr)
|
|
433
|
+
return buckets
|
devmem/agent_colors.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Stable, lightweight colours for live agent identities."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
PALETTE = (
|
|
8
|
+
("Orange", "#FF8C42"), ("Teal", "#2DD4BF"), ("Pink", "#F472B6"),
|
|
9
|
+
("Yellow", "#FACC15"), ("Blue", "#60A5FA"), ("Green", "#4ADE80"),
|
|
10
|
+
("Purple", "#C084FC"), ("Red", "#F87171"), ("White", "#E5E7EB"),
|
|
11
|
+
("Lime", "#A3E635"), ("Brown", "#C08457"), ("Cyan", "#22D3EE"),
|
|
12
|
+
)
|
|
13
|
+
RECENT_FOR = timedelta(hours=24)
|
|
14
|
+
PRUNE_AFTER = timedelta(days=30)
|
|
15
|
+
REFRESH_AFTER = timedelta(minutes=5)
|
|
16
|
+
_BY_NAME = dict(PALETTE)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _time(value: str) -> datetime | None:
|
|
20
|
+
try:
|
|
21
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
22
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
23
|
+
except (AttributeError, ValueError):
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def color_for(conn, agent_key: str) -> dict[str, str] | None:
|
|
28
|
+
"""Get an agent's durable palette entry, assigning one when needed."""
|
|
29
|
+
if not agent_key:
|
|
30
|
+
return None
|
|
31
|
+
current = datetime.now(UTC)
|
|
32
|
+
stamp = current.isoformat()
|
|
33
|
+
|
|
34
|
+
def result(row):
|
|
35
|
+
return {"name": row[0], "hex": _BY_NAME[row[0]]} if row[0] in _BY_NAME else None
|
|
36
|
+
|
|
37
|
+
row = conn.execute("SELECT color,last_seen FROM agent_colors WHERE agent_key=?", (agent_key,)).fetchone()
|
|
38
|
+
if row and (seen := _time(row[1])) is not None and current - seen <= REFRESH_AFTER:
|
|
39
|
+
return result(row)
|
|
40
|
+
|
|
41
|
+
owns_transaction = not conn.in_transaction
|
|
42
|
+
if owns_transaction:
|
|
43
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
44
|
+
else:
|
|
45
|
+
conn.execute("SAVEPOINT agent_colors")
|
|
46
|
+
try:
|
|
47
|
+
# Another session may have assigned the colour while this one waited for the lock.
|
|
48
|
+
row = conn.execute("SELECT color,last_seen FROM agent_colors WHERE agent_key=?", (agent_key,)).fetchone()
|
|
49
|
+
if row:
|
|
50
|
+
seen = _time(row[1])
|
|
51
|
+
if seen is None or current - seen > REFRESH_AFTER:
|
|
52
|
+
conn.execute("UPDATE agent_colors SET last_seen=? WHERE agent_key=?", (stamp, agent_key))
|
|
53
|
+
value = result(row)
|
|
54
|
+
else:
|
|
55
|
+
conn.execute("DELETE FROM agent_colors WHERE last_seen<?", ((current - PRUNE_AFTER).isoformat(),))
|
|
56
|
+
held = {row[0] for row in conn.execute("SELECT color FROM agent_colors WHERE last_seen>=?", ((current - RECENT_FOR).isoformat(),))}
|
|
57
|
+
available = next((name for name, _ in PALETTE if name not in held), None)
|
|
58
|
+
if available is None:
|
|
59
|
+
oldest = conn.execute("SELECT color FROM agent_colors ORDER BY last_seen LIMIT 1").fetchone()
|
|
60
|
+
available = oldest[0] if oldest and oldest[0] in _BY_NAME else PALETTE[0][0]
|
|
61
|
+
conn.execute("INSERT INTO agent_colors(agent_key,color,last_seen) VALUES(?,?,?)", (agent_key, available, stamp))
|
|
62
|
+
value = {"name": available, "hex": _BY_NAME[available]}
|
|
63
|
+
if owns_transaction:
|
|
64
|
+
conn.commit()
|
|
65
|
+
else:
|
|
66
|
+
conn.execute("RELEASE agent_colors")
|
|
67
|
+
return value
|
|
68
|
+
except Exception:
|
|
69
|
+
if owns_transaction:
|
|
70
|
+
conn.rollback()
|
|
71
|
+
else:
|
|
72
|
+
conn.execute("ROLLBACK TO agent_colors")
|
|
73
|
+
conn.execute("RELEASE agent_colors")
|
|
74
|
+
raise
|