devscontext 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.
- devscontext/__init__.py +3 -0
- devscontext/adapters/__init__.py +23 -0
- devscontext/adapters/base.py +105 -0
- devscontext/adapters/fireflies.py +585 -0
- devscontext/adapters/gmail.py +580 -0
- devscontext/adapters/jira.py +639 -0
- devscontext/adapters/local_docs.py +984 -0
- devscontext/adapters/slack.py +804 -0
- devscontext/agents/__init__.py +28 -0
- devscontext/agents/preprocessor.py +775 -0
- devscontext/agents/watcher.py +265 -0
- devscontext/cache.py +151 -0
- devscontext/cli.py +727 -0
- devscontext/config.py +264 -0
- devscontext/constants.py +107 -0
- devscontext/core.py +582 -0
- devscontext/exceptions.py +148 -0
- devscontext/logging.py +181 -0
- devscontext/models.py +504 -0
- devscontext/plugins/__init__.py +49 -0
- devscontext/plugins/base.py +321 -0
- devscontext/plugins/registry.py +544 -0
- devscontext/py.typed +0 -0
- devscontext/rag/__init__.py +113 -0
- devscontext/rag/embeddings.py +296 -0
- devscontext/rag/index.py +323 -0
- devscontext/server.py +374 -0
- devscontext/storage.py +321 -0
- devscontext/synthesis.py +1057 -0
- devscontext/utils.py +297 -0
- devscontext-0.1.0.dist-info/METADATA +253 -0
- devscontext-0.1.0.dist-info/RECORD +35 -0
- devscontext-0.1.0.dist-info/WHEEL +4 -0
- devscontext-0.1.0.dist-info/entry_points.txt +2 -0
- devscontext-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Jira watcher for detecting tickets ready for pre-processing.
|
|
2
|
+
|
|
3
|
+
This module provides polling-based detection of Jira tickets that have
|
|
4
|
+
moved to a target status (e.g., "Ready for Development"). When new tickets
|
|
5
|
+
are detected, they are passed to the preprocessing pipeline.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
watcher = JiraWatcher(config, pipeline)
|
|
9
|
+
await watcher.run() # Runs until stopped
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from devscontext.logging import get_logger
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from devscontext.agents.preprocessor import PreprocessingPipeline
|
|
23
|
+
from devscontext.models import DevsContextConfig
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
# Jira API paths
|
|
28
|
+
JIRA_API_BASE_PATH = "/rest/api/3"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JiraWatcher:
|
|
32
|
+
"""Watches Jira for tickets in target status.
|
|
33
|
+
|
|
34
|
+
Polls Jira periodically using JQL to find tickets in the configured
|
|
35
|
+
status (e.g., "Ready for Development"). New tickets are passed to
|
|
36
|
+
the preprocessing pipeline.
|
|
37
|
+
|
|
38
|
+
Tracks already-processed tickets to avoid reprocessing.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
config: DevsContextConfig,
|
|
44
|
+
pipeline: PreprocessingPipeline,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Initialize the watcher.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
config: DevsContext configuration.
|
|
50
|
+
pipeline: Preprocessing pipeline to handle new tickets.
|
|
51
|
+
"""
|
|
52
|
+
self._config = config
|
|
53
|
+
self._pipeline = pipeline
|
|
54
|
+
self._preprocessor_config = config.agents.preprocessor
|
|
55
|
+
self._jira_config = config.sources.jira
|
|
56
|
+
|
|
57
|
+
self._processed_tickets: set[str] = set()
|
|
58
|
+
self._running = False
|
|
59
|
+
self._client: httpx.AsyncClient | None = None
|
|
60
|
+
|
|
61
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
62
|
+
"""Get or create HTTP client for Jira API."""
|
|
63
|
+
if self._client is None:
|
|
64
|
+
auth = (self._jira_config.email, self._jira_config.api_token)
|
|
65
|
+
self._client = httpx.AsyncClient(
|
|
66
|
+
base_url=self._jira_config.base_url,
|
|
67
|
+
auth=auth,
|
|
68
|
+
headers={"Accept": "application/json"},
|
|
69
|
+
timeout=30.0,
|
|
70
|
+
)
|
|
71
|
+
return self._client
|
|
72
|
+
|
|
73
|
+
def _build_jql(self) -> str:
|
|
74
|
+
"""Build JQL query for finding ready tickets.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
JQL query string.
|
|
78
|
+
"""
|
|
79
|
+
# Handle single project or list of projects
|
|
80
|
+
projects = self._preprocessor_config.jira_project
|
|
81
|
+
if isinstance(projects, list):
|
|
82
|
+
project_clause = f"project IN ({', '.join(projects)})"
|
|
83
|
+
else:
|
|
84
|
+
project_clause = f'project = "{projects}"'
|
|
85
|
+
|
|
86
|
+
# Escape status for JQL
|
|
87
|
+
status = self._preprocessor_config.jira_status.replace('"', '\\"')
|
|
88
|
+
|
|
89
|
+
# Look for tickets updated in the last hour (configurable polling)
|
|
90
|
+
# This ensures we don't miss tickets between polls
|
|
91
|
+
jql = f'{project_clause} AND status = "{status}" AND updated >= -1h ORDER BY updated DESC'
|
|
92
|
+
|
|
93
|
+
return jql
|
|
94
|
+
|
|
95
|
+
async def poll_once(self) -> list[str]:
|
|
96
|
+
"""Single poll - returns list of new task IDs ready for processing.
|
|
97
|
+
|
|
98
|
+
Queries Jira for tickets in the target status and filters out
|
|
99
|
+
already-processed tickets.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
List of new task IDs that need processing.
|
|
103
|
+
"""
|
|
104
|
+
if not self._jira_config.enabled:
|
|
105
|
+
logger.warning("Jira adapter not enabled")
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
client = self._get_client()
|
|
109
|
+
jql = self._build_jql()
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
response = await client.get(
|
|
113
|
+
f"{JIRA_API_BASE_PATH}/search",
|
|
114
|
+
params={
|
|
115
|
+
"jql": jql,
|
|
116
|
+
"maxResults": 50,
|
|
117
|
+
"fields": "key", # Only need the key
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
response.raise_for_status()
|
|
121
|
+
data = response.json()
|
|
122
|
+
|
|
123
|
+
except httpx.HTTPStatusError as e:
|
|
124
|
+
logger.error(
|
|
125
|
+
"Jira API error during poll",
|
|
126
|
+
extra={"status_code": e.response.status_code, "jql": jql},
|
|
127
|
+
)
|
|
128
|
+
return []
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.error("Error polling Jira", extra={"error": str(e)})
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
# Extract ticket IDs
|
|
134
|
+
issues = data.get("issues", [])
|
|
135
|
+
all_tickets = [issue["key"] for issue in issues]
|
|
136
|
+
|
|
137
|
+
# Filter out already-processed tickets
|
|
138
|
+
new_tickets = [t for t in all_tickets if t not in self._processed_tickets]
|
|
139
|
+
|
|
140
|
+
if new_tickets:
|
|
141
|
+
logger.info(
|
|
142
|
+
"Found new tickets",
|
|
143
|
+
extra={"count": len(new_tickets), "tickets": new_tickets},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return new_tickets
|
|
147
|
+
|
|
148
|
+
async def process_ticket(self, task_id: str) -> bool:
|
|
149
|
+
"""Process a single ticket through the pipeline.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
task_id: Jira ticket ID to process.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if processed successfully, False otherwise.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
logger.info("Processing ticket", extra={"task_id": task_id})
|
|
159
|
+
await self._pipeline.process(task_id)
|
|
160
|
+
self._processed_tickets.add(task_id)
|
|
161
|
+
logger.info("Ticket processed successfully", extra={"task_id": task_id})
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
except Exception as e:
|
|
165
|
+
logger.error(
|
|
166
|
+
"Failed to process ticket",
|
|
167
|
+
extra={"task_id": task_id, "error": str(e)},
|
|
168
|
+
)
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
async def run(self) -> None:
|
|
172
|
+
"""Run polling loop until stopped.
|
|
173
|
+
|
|
174
|
+
Polls Jira every N minutes (configurable) and processes new tickets.
|
|
175
|
+
Use stop() to signal the loop to exit.
|
|
176
|
+
"""
|
|
177
|
+
self._running = True
|
|
178
|
+
poll_interval = self._preprocessor_config.trigger.poll_interval_minutes * 60
|
|
179
|
+
|
|
180
|
+
logger.info(
|
|
181
|
+
"Starting Jira watcher",
|
|
182
|
+
extra={
|
|
183
|
+
"poll_interval_minutes": self._preprocessor_config.trigger.poll_interval_minutes,
|
|
184
|
+
"jira_status": self._preprocessor_config.jira_status,
|
|
185
|
+
"jira_project": self._preprocessor_config.jira_project,
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
while self._running:
|
|
190
|
+
try:
|
|
191
|
+
# Poll for new tickets
|
|
192
|
+
new_tickets = await self.poll_once()
|
|
193
|
+
|
|
194
|
+
# Process each new ticket
|
|
195
|
+
for task_id in new_tickets:
|
|
196
|
+
if not self._running:
|
|
197
|
+
break
|
|
198
|
+
await self.process_ticket(task_id)
|
|
199
|
+
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.error("Error in polling loop", extra={"error": str(e)})
|
|
202
|
+
|
|
203
|
+
# Wait for next poll interval
|
|
204
|
+
if self._running:
|
|
205
|
+
logger.debug(
|
|
206
|
+
"Waiting for next poll",
|
|
207
|
+
extra={"interval_seconds": poll_interval},
|
|
208
|
+
)
|
|
209
|
+
await asyncio.sleep(poll_interval)
|
|
210
|
+
|
|
211
|
+
logger.info("Jira watcher stopped")
|
|
212
|
+
|
|
213
|
+
async def run_once(self) -> int:
|
|
214
|
+
"""Single run: check for ready tickets, process them, exit.
|
|
215
|
+
|
|
216
|
+
Useful for cron jobs or CI pipelines.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Number of tickets processed.
|
|
220
|
+
"""
|
|
221
|
+
logger.info("Running single poll cycle")
|
|
222
|
+
|
|
223
|
+
new_tickets = await self.poll_once()
|
|
224
|
+
processed_count = 0
|
|
225
|
+
|
|
226
|
+
for task_id in new_tickets:
|
|
227
|
+
if await self.process_ticket(task_id):
|
|
228
|
+
processed_count += 1
|
|
229
|
+
|
|
230
|
+
logger.info(
|
|
231
|
+
"Single poll cycle complete",
|
|
232
|
+
extra={"processed": processed_count, "total": len(new_tickets)},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return processed_count
|
|
236
|
+
|
|
237
|
+
def stop(self) -> None:
|
|
238
|
+
"""Signal watcher to stop.
|
|
239
|
+
|
|
240
|
+
The polling loop will exit after the current iteration completes.
|
|
241
|
+
"""
|
|
242
|
+
logger.info("Stopping Jira watcher")
|
|
243
|
+
self._running = False
|
|
244
|
+
|
|
245
|
+
async def close(self) -> None:
|
|
246
|
+
"""Close HTTP client and clean up resources."""
|
|
247
|
+
if self._client is not None:
|
|
248
|
+
await self._client.aclose()
|
|
249
|
+
self._client = None
|
|
250
|
+
|
|
251
|
+
def get_processed_count(self) -> int:
|
|
252
|
+
"""Get number of tickets processed in this session.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Number of unique tickets processed.
|
|
256
|
+
"""
|
|
257
|
+
return len(self._processed_tickets)
|
|
258
|
+
|
|
259
|
+
def clear_processed(self) -> None:
|
|
260
|
+
"""Clear the set of processed tickets.
|
|
261
|
+
|
|
262
|
+
Use this to allow reprocessing of tickets in the next poll.
|
|
263
|
+
"""
|
|
264
|
+
self._processed_tickets.clear()
|
|
265
|
+
logger.debug("Cleared processed tickets set")
|
devscontext/cache.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Simple TTL cache for DevsContext.
|
|
2
|
+
|
|
3
|
+
This module provides a simple in-memory cache with time-to-live (TTL)
|
|
4
|
+
expiration. It's used to cache context fetching results to avoid
|
|
5
|
+
repeated API calls.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
cache = SimpleCache(ttl=900, max_size=100) # 15 min TTL
|
|
9
|
+
cache.set("key", {"data": "value"})
|
|
10
|
+
result = cache.get("key") # Returns {"data": "value"}
|
|
11
|
+
# After 15 minutes...
|
|
12
|
+
result = cache.get("key") # Returns None (expired)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from devscontext.constants import DEFAULT_CACHE_MAX_SIZE, DEFAULT_CACHE_TTL_SECONDS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CacheEntry:
|
|
24
|
+
"""A cache entry with value and expiration time.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
value: The cached value.
|
|
28
|
+
expires_at: Monotonic time when this entry expires.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("expires_at", "value")
|
|
32
|
+
|
|
33
|
+
def __init__(self, value: Any, ttl: float) -> None:
|
|
34
|
+
"""Initialize a cache entry.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
value: The value to cache.
|
|
38
|
+
ttl: Time-to-live in seconds.
|
|
39
|
+
"""
|
|
40
|
+
self.value = value
|
|
41
|
+
self.expires_at = time.monotonic() + ttl
|
|
42
|
+
|
|
43
|
+
def is_expired(self) -> bool:
|
|
44
|
+
"""Check if this entry has expired.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
True if expired, False otherwise.
|
|
48
|
+
"""
|
|
49
|
+
return time.monotonic() > self.expires_at
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SimpleCache:
|
|
53
|
+
"""Simple in-memory TTL cache.
|
|
54
|
+
|
|
55
|
+
Uses a dict with timestamps for expiration. Evicts expired entries
|
|
56
|
+
lazily on access and when the cache is full.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
_cache: The underlying cache dictionary.
|
|
60
|
+
_ttl: Time-to-live in seconds for new entries.
|
|
61
|
+
_max_size: Maximum number of entries.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
ttl: float = DEFAULT_CACHE_TTL_SECONDS,
|
|
67
|
+
max_size: int = DEFAULT_CACHE_MAX_SIZE,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Initialize the cache.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
ttl: Time-to-live in seconds for cache entries. Default 15 minutes.
|
|
73
|
+
max_size: Maximum number of items in cache. Default 100.
|
|
74
|
+
"""
|
|
75
|
+
self._cache: dict[str, CacheEntry] = {}
|
|
76
|
+
self._ttl = ttl
|
|
77
|
+
self._max_size = max_size
|
|
78
|
+
|
|
79
|
+
def get(self, key: str) -> Any | None:
|
|
80
|
+
"""Get a value from the cache.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
key: Cache key.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Cached value or None if not found/expired.
|
|
87
|
+
"""
|
|
88
|
+
entry = self._cache.get(key)
|
|
89
|
+
|
|
90
|
+
if entry is None:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
if entry.is_expired():
|
|
94
|
+
del self._cache[key]
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
return entry.value
|
|
98
|
+
|
|
99
|
+
def set(self, key: str, value: Any) -> None:
|
|
100
|
+
"""Set a value in the cache.
|
|
101
|
+
|
|
102
|
+
Evicts expired entries if cache is full, then evicts the oldest
|
|
103
|
+
entry if still at capacity.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
key: Cache key.
|
|
107
|
+
value: Value to cache.
|
|
108
|
+
"""
|
|
109
|
+
# Evict expired entries if we're at max size
|
|
110
|
+
if len(self._cache) >= self._max_size and key not in self._cache:
|
|
111
|
+
self._evict_expired()
|
|
112
|
+
|
|
113
|
+
# If still at max size, evict oldest entry
|
|
114
|
+
if len(self._cache) >= self._max_size and key not in self._cache:
|
|
115
|
+
oldest_key = next(iter(self._cache))
|
|
116
|
+
del self._cache[oldest_key]
|
|
117
|
+
|
|
118
|
+
self._cache[key] = CacheEntry(value, self._ttl)
|
|
119
|
+
|
|
120
|
+
def invalidate(self, key: str) -> None:
|
|
121
|
+
"""Remove a specific key from the cache.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
key: Cache key to remove.
|
|
125
|
+
"""
|
|
126
|
+
self._cache.pop(key, None)
|
|
127
|
+
|
|
128
|
+
def clear(self) -> None:
|
|
129
|
+
"""Clear all cache entries."""
|
|
130
|
+
self._cache.clear()
|
|
131
|
+
|
|
132
|
+
def _evict_expired(self) -> None:
|
|
133
|
+
"""Remove all expired entries from the cache."""
|
|
134
|
+
now = time.monotonic()
|
|
135
|
+
expired_keys = [key for key, entry in self._cache.items() if now > entry.expires_at]
|
|
136
|
+
for key in expired_keys:
|
|
137
|
+
del self._cache[key]
|
|
138
|
+
|
|
139
|
+
def __len__(self) -> int:
|
|
140
|
+
"""Return the number of entries in the cache.
|
|
141
|
+
|
|
142
|
+
Note: This includes potentially expired entries.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Number of cache entries.
|
|
146
|
+
"""
|
|
147
|
+
return len(self._cache)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Alias for backwards compatibility
|
|
151
|
+
ContextCache = SimpleCache
|