Spaces:
Runtime error
Runtime error
Update app.py
Browse files
app.py
CHANGED
@@ -94,13 +94,18 @@ class WebhookHandler(BaseHTTPRequestHandler):
|
|
94 |
if event == 'issues' and WebhookHandler.manager_instance:
|
95 |
action = payload.get('action')
|
96 |
logger.info(f"Issue action: {action}")
|
97 |
-
if action in ['opened', 'reopened', 'closed', 'assigned']:
|
98 |
# Ensure the event loop is running in the webhook thread if needed
|
99 |
-
loop
|
100 |
-
asyncio.
|
101 |
-
|
102 |
-
|
103 |
-
|
|
|
|
|
|
|
|
|
|
|
104 |
elif event == 'ping':
|
105 |
logger.info("Received GitHub webhook ping.")
|
106 |
else:
|
@@ -130,8 +135,9 @@ class IssueManager:
|
|
130 |
self.issue_clusters: Dict[int, List[int]] = {} # Store clusters: {cluster_id: [issue_index1, issue_index2]}
|
131 |
self.issue_list_for_clustering: List[dict] = [] # Store issues in list order for clustering index mapping
|
132 |
# self._init_local_models() # Consider lazy loading or conditional loading
|
133 |
-
self.ws_clients: List[websockets.
|
134 |
self.code_editors: Dict[int, OTCodeEditor] = {} # Store code editors for each issue
|
|
|
135 |
|
136 |
# Placeholder for local model initialization - implement actual loading if needed
|
137 |
def _init_local_models(self):
|
@@ -146,7 +152,7 @@ class IssueManager:
|
|
146 |
return hashlib.md5(content.encode()).hexdigest()
|
147 |
|
148 |
@lru_cache(maxsize=100)
|
149 |
-
async def cached_suggestion(self, issue_hash: str,
|
150 |
# Find the issue corresponding to the hash (inefficient, improve if needed)
|
151 |
found_issue = None
|
152 |
for issue in self.issues.values():
|
@@ -155,9 +161,13 @@ class IssueManager:
|
|
155 |
break
|
156 |
if not found_issue:
|
157 |
return "Error: Issue not found for the given hash."
|
|
|
|
|
|
|
|
|
|
|
|
|
158 |
|
159 |
-
logger.info(f"Cache miss or first request for issue hash {issue_hash}. Requesting suggestion from {model}.")
|
160 |
-
return await self.suggest_resolution(found_issue, model)
|
161 |
|
162 |
async def handle_webhook_event(self, event: str, action: str, payload: dict):
|
163 |
logger.info(f"Processing webhook event: {event}, action: {action}")
|
@@ -171,31 +181,53 @@ class IssueManager:
|
|
171 |
logger.warning("Webhook issue data missing 'number'.")
|
172 |
return
|
173 |
|
|
|
174 |
if action == 'closed':
|
175 |
logger.info(f"Removing closed issue {issue_number} from active list.")
|
176 |
-
self.issues.pop(issue_number, None)
|
|
|
177 |
# Optionally remove associated editor, etc.
|
178 |
self.code_editors.pop(issue_number, None)
|
179 |
elif action in ['opened', 'reopened', 'edited']: # Handle edited issues too
|
180 |
logger.info(f"Adding/Updating issue {issue_number} from webhook.")
|
181 |
-
self.issues[issue_number] = issue_data
|
|
|
182 |
# Potentially trigger re-clustering or update specific issue details
|
183 |
elif action == 'assigned':
|
184 |
logger.info(f"Issue {issue_number} assigned to {payload.get('assignee', {}).get('login', 'N/A')}")
|
185 |
-
self.issues
|
|
|
|
|
|
|
|
|
|
|
|
|
186 |
else:
|
187 |
logger.info(f"Ignoring action '{action}' for issue {issue_number}.")
|
188 |
|
189 |
-
#
|
190 |
-
|
191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
192 |
|
193 |
async def crawl_issues(self, repo_url: str, github_token: str, hf_token: str) -> Tuple[List[List], go.Figure, str]:
|
194 |
"""
|
195 |
Crawls issues, updates internal state, performs clustering, and returns data for UI update.
|
196 |
"""
|
197 |
-
if not repo_url or not
|
198 |
-
return [], go.Figure(), "Error: Repository URL
|
199 |
|
200 |
logger.info(f"Starting issue crawl for {repo_url}")
|
201 |
self.repo_url = repo_url
|
@@ -209,31 +241,39 @@ class IssueManager:
|
|
209 |
logger.error(f"Invalid GitHub URL format: {repo_url}")
|
210 |
return [], go.Figure(), "Error: Invalid GitHub URL format. Use https://github.com/owner/repo"
|
211 |
owner, repo_name = match.groups()
|
212 |
-
api_url = f"{GITHUB_API}/{owner}/{repo_name}/issues?state=open" # Fetch only open issues
|
213 |
|
214 |
headers = {
|
215 |
-
"Authorization": f"token {github_token}",
|
216 |
"Accept": "application/vnd.github.v3+json"
|
217 |
}
|
|
|
|
|
|
|
218 |
|
219 |
try:
|
|
|
|
|
|
|
220 |
async with aiohttp.ClientSession(headers=headers) as session:
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
|
|
|
|
|
|
237 |
|
238 |
if not self.issues:
|
239 |
logger.info("No open issues found.")
|
@@ -279,10 +319,12 @@ class IssueManager:
|
|
279 |
error_msg = f"Error: Repository not found at {repo_url}. Check the URL."
|
280 |
elif e.status == 401:
|
281 |
error_msg = "Error: Invalid GitHub token or insufficient permissions."
|
|
|
|
|
282 |
return [], go.Figure(), error_msg
|
283 |
except GitCommandError as e:
|
284 |
-
logger.error(f"Git clone error: {e}")
|
285 |
-
return [], go.Figure(), f"Error
|
286 |
except Exception as e:
|
287 |
logger.exception(f"An unexpected error occurred during issue crawl: {e}") # Log full traceback
|
288 |
return [], go.Figure(), f"An unexpected error occurred: {e}"
|
@@ -297,8 +339,29 @@ class IssueManager:
|
|
297 |
|
298 |
def _generate_stats_plot(self, severity_counts: Dict[str, int]) -> go.Figure:
|
299 |
"""Generates a Plotly bar chart for issue severity distribution."""
|
300 |
-
severities
|
301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
302 |
|
303 |
fig = px.bar(
|
304 |
x=severities,
|
@@ -312,15 +375,18 @@ class IssueManager:
|
|
312 |
'Medium': '#FACC15', # Yellow
|
313 |
'Low': '#84CC16', # Lime
|
314 |
'Unknown': '#6B7280' # Gray
|
315 |
-
}
|
|
|
316 |
)
|
317 |
fig.update_layout(
|
318 |
-
|
319 |
yaxis_title="Number of Issues",
|
320 |
-
xaxis_title="Severity Level",
|
321 |
plot_bgcolor='rgba(0,0,0,0)', # Transparent background
|
322 |
-
paper_bgcolor='rgba(0,0,0,0)'
|
|
|
|
|
323 |
)
|
|
|
324 |
return fig
|
325 |
|
326 |
async def _cluster_similar_issues(self):
|
@@ -332,9 +398,12 @@ class IssueManager:
|
|
332 |
|
333 |
logger.info("Generating embeddings for clustering...")
|
334 |
try:
|
335 |
-
|
336 |
-
|
337 |
-
|
|
|
|
|
|
|
338 |
self.issue_clusters = {}
|
339 |
return
|
340 |
|
@@ -347,14 +416,16 @@ class IssueManager:
|
|
347 |
clusters = clusterer.fit_predict(embeddings)
|
348 |
|
349 |
self.issue_clusters = {}
|
|
|
350 |
for i, cluster_id in enumerate(clusters):
|
351 |
if cluster_id == -1: # HDBSCAN uses -1 for noise points
|
|
|
352 |
continue # Skip noise points
|
353 |
if cluster_id not in self.issue_clusters:
|
354 |
self.issue_clusters[cluster_id] = []
|
355 |
self.issue_clusters[cluster_id].append(i) # Store original index
|
356 |
|
357 |
-
logger.info(f"Clustering complete. Found {len(self.issue_clusters)} clusters
|
358 |
|
359 |
except Exception as e:
|
360 |
logger.exception(f"Error during issue clustering: {e}")
|
@@ -372,21 +443,35 @@ class IssueManager:
|
|
372 |
headers = {"Authorization": f"Bearer {self.hf_token}"}
|
373 |
|
374 |
logger.info(f"Requesting embeddings from {api_url} for {len(texts)} texts.")
|
375 |
-
|
|
|
|
|
376 |
try:
|
377 |
-
|
|
|
|
|
378 |
response.raise_for_status()
|
379 |
result = await response.json()
|
380 |
# Check if the result is a list of embeddings (floats)
|
381 |
if isinstance(result, list) and all(isinstance(emb, list) for emb in result):
|
382 |
logger.info(f"Successfully received {len(result)} embeddings.")
|
383 |
return result
|
|
|
|
|
|
|
384 |
else:
|
385 |
logger.error(f"Unexpected embedding format received: {type(result)}. Full response: {result}")
|
386 |
return None
|
387 |
except aiohttp.ClientResponseError as e:
|
388 |
logger.error(f"HF Inference API request failed: {e.status} {e.message}")
|
389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
390 |
return None
|
391 |
except Exception as e:
|
392 |
logger.exception(f"An unexpected error occurred during embedding generation: {e}")
|
@@ -405,32 +490,29 @@ class IssueManager:
|
|
405 |
model_id = HF_MODELS[model_key]
|
406 |
logger.info(f"Generating patch for issue {issue_number} using model {model_id}")
|
407 |
|
408 |
-
# --- Context Gathering (
|
409 |
-
|
410 |
-
# - Identify relevant files based on issue text, stack traces, etc.
|
411 |
-
# - Potentially use git history or blame to find relevant code sections.
|
412 |
-
# For now, we'll use a placeholder or skip context if too complex.
|
413 |
-
context = "Context gathering not implemented. Provide code snippets in the issue description."
|
414 |
# context = await self._get_code_context(issue_number) # Uncomment if implemented
|
415 |
|
416 |
# --- Prompt Engineering ---
|
417 |
-
prompt = f"""You are an expert programmer
|
418 |
-
Analyze the following issue and provide a code patch in standard `diff` format.
|
419 |
-
Focus only on the necessary changes to resolve the problem described.
|
420 |
-
Explain your reasoning briefly before the patch.
|
421 |
|
422 |
-
## Issue
|
423 |
-
|
|
|
424 |
{issue.get('body', 'N/A')}
|
|
|
425 |
|
426 |
## Relevant Code Context (if available):
|
427 |
{context}
|
428 |
|
429 |
## Instructions:
|
430 |
-
1.
|
431 |
-
2.
|
432 |
-
3.
|
433 |
-
4.
|
|
|
|
|
434 |
|
435 |
## Patch Suggestion:
|
436 |
"""
|
@@ -441,39 +523,60 @@ Explain your reasoning briefly before the patch.
|
|
441 |
payload = {
|
442 |
"inputs": prompt,
|
443 |
"parameters": { # Adjust parameters as needed
|
444 |
-
"max_new_tokens":
|
445 |
-
"temperature": 0.
|
446 |
"return_full_text": False, # Only get the generated part
|
447 |
-
"do_sample":
|
448 |
-
"top_p": 0.9,
|
449 |
-
}
|
|
|
450 |
}
|
|
|
451 |
|
452 |
try:
|
453 |
-
async with aiohttp.ClientSession(headers=headers) as session:
|
454 |
async with session.post(api_url, json=payload) as response:
|
455 |
response.raise_for_status()
|
456 |
result = await response.json()
|
457 |
-
if result and isinstance(result, list):
|
458 |
generated_text = result[0].get('generated_text', '')
|
459 |
logger.info(f"Received patch suggestion from {model_id}")
|
460 |
-
#
|
461 |
diff_match = re.search(r"```diff\n(.*?)```", generated_text, re.DOTALL)
|
462 |
explanation = generated_text.split("```diff")[0].strip()
|
463 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
464 |
|
465 |
return {
|
466 |
"explanation": explanation,
|
467 |
"patch": patch,
|
468 |
"model_used": model_id
|
469 |
}
|
|
|
|
|
|
|
470 |
else:
|
471 |
-
logger.error(f"Unexpected response format from {model_id}: {result}")
|
472 |
return {"error": "Received unexpected response format from AI model."}
|
473 |
except aiohttp.ClientResponseError as e:
|
474 |
logger.error(f"HF Inference API request failed for patch generation: {e.status} {e.message}")
|
475 |
-
|
|
|
476 |
return {"error": f"AI model request failed ({e.status}). Check model availability and HF token."}
|
|
|
|
|
|
|
477 |
except Exception as e:
|
478 |
logger.exception(f"Error generating code patch: {e}")
|
479 |
return {"error": f"An unexpected error occurred: {e}"}
|
@@ -504,44 +607,61 @@ Explain your reasoning briefly before the patch.
|
|
504 |
model_id = HF_MODELS[model_key]
|
505 |
logger.info(f"Requesting resolution suggestion for issue {issue.get('id','N/A')} using {model_id}")
|
506 |
|
507 |
-
prompt = f"""Analyze the following GitHub issue and provide a concise, step-by-step suggestion on how to resolve it. Focus on the technical steps required.
|
508 |
|
509 |
-
## Issue
|
510 |
-
|
|
|
511 |
{issue.get('body', 'N/A')}
|
512 |
-
|
513 |
|
514 |
## Suggested Resolution Steps:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
515 |
"""
|
516 |
api_url = f"{HF_INFERENCE_API}/{model_id}"
|
517 |
headers = {"Authorization": f"Bearer {self.hf_token}"}
|
518 |
payload = {
|
519 |
"inputs": prompt,
|
520 |
"parameters": {
|
521 |
-
"max_new_tokens":
|
522 |
-
"temperature": 0.
|
523 |
"return_full_text": False,
|
524 |
"do_sample": True,
|
525 |
"top_p": 0.95,
|
526 |
-
}
|
|
|
527 |
}
|
|
|
528 |
|
529 |
try:
|
530 |
-
async with aiohttp.ClientSession(headers=headers) as session:
|
531 |
async with session.post(api_url, json=payload) as response:
|
532 |
response.raise_for_status()
|
533 |
result = await response.json()
|
534 |
-
if result and isinstance(result, list):
|
535 |
suggestion = result[0].get('generated_text', 'No suggestion generated.')
|
536 |
logger.info(f"Received suggestion from {model_id}")
|
537 |
return suggestion.strip()
|
|
|
|
|
|
|
538 |
else:
|
539 |
logger.error(f"Unexpected response format from {model_id} for suggestion: {result}")
|
540 |
return "Error: Received unexpected response format from AI model."
|
541 |
except aiohttp.ClientResponseError as e:
|
542 |
logger.error(f"HF Inference API request failed for suggestion: {e.status} {e.message}")
|
543 |
-
|
|
|
544 |
return f"Error: AI model request failed ({e.status}). Check model availability and HF token."
|
|
|
|
|
|
|
545 |
except Exception as e:
|
546 |
logger.exception(f"Error suggesting resolution: {e}")
|
547 |
return f"An unexpected error occurred: {e}"
|
@@ -555,37 +675,54 @@ Explain your reasoning briefly before the patch.
|
|
555 |
if not self.ws_clients:
|
556 |
continue
|
557 |
|
|
|
558 |
status_payload = json.dumps({
|
559 |
"type": "collaboration_status",
|
560 |
"collaborators": self.collaborators
|
561 |
})
|
|
|
|
|
|
|
562 |
# Use asyncio.gather to send concurrently, handling potential errors
|
563 |
results = await asyncio.gather(
|
564 |
*[client.send(status_payload) for client in self.ws_clients],
|
565 |
return_exceptions=True # Don't let one failed send stop others
|
566 |
)
|
567 |
# Log any errors that occurred during broadcast
|
|
|
568 |
for i, result in enumerate(results):
|
|
|
569 |
if isinstance(result, Exception):
|
570 |
-
logger.warning(f"Failed to send status to client {
|
|
|
|
|
|
|
|
|
|
|
571 |
|
572 |
|
573 |
async def handle_code_editor_update(self, issue_num: int, delta: str, client_id: str):
|
574 |
"""Applies a delta from one client and broadcasts it to others."""
|
575 |
if issue_num not in self.code_editors:
|
576 |
-
|
577 |
-
|
|
|
|
|
|
|
|
|
578 |
|
579 |
try:
|
580 |
# Apply the delta to the server-side authoritative state
|
581 |
-
|
|
|
|
|
582 |
logger.info(f"Applied delta for issue {issue_num} from client {client_id}")
|
583 |
|
584 |
# Broadcast the delta to all *other* connected clients
|
585 |
update_payload = json.dumps({
|
586 |
"type": "code_update",
|
587 |
"issue_num": issue_num,
|
588 |
-
"delta": delta # Send the original delta
|
589 |
})
|
590 |
|
591 |
tasks = []
|
@@ -600,7 +737,8 @@ Explain your reasoning briefly before the patch.
|
|
600 |
# Log errors during broadcast
|
601 |
for i, result in enumerate(results):
|
602 |
if isinstance(result, Exception):
|
603 |
-
|
|
|
604 |
|
605 |
except json.JSONDecodeError:
|
606 |
logger.error(f"Received invalid JSON delta for issue {issue_num}: {delta}")
|
@@ -619,8 +757,20 @@ Explain your reasoning briefly before the patch.
|
|
619 |
return_exceptions=True
|
620 |
)
|
621 |
for i, result in enumerate(results):
|
|
|
622 |
if isinstance(result, Exception):
|
623 |
-
logger.warning(f"Failed to send issue update notification to client {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
624 |
|
625 |
|
626 |
# ========== Gradio UI Definition ==========
|
@@ -633,47 +783,62 @@ def create_ui(manager: IssueManager):
|
|
633 |
if issue_num is None or issue_num not in manager.issues:
|
634 |
return "<p>Select an issue from the board to see details.</p>"
|
635 |
issue = manager.issues[issue_num]
|
636 |
-
# Convert markdown body to HTML
|
637 |
-
html_body = markdown2.markdown(
|
|
|
|
|
|
|
638 |
# Basic styling
|
639 |
preview_html = f"""
|
640 |
-
<div style="border: 1px solid #e5e7eb; padding: 15px; border-radius: 8px; background-color: #f9fafb;">
|
641 |
-
<h4
|
|
|
|
|
|
|
|
|
642 |
<hr style='margin: 10px 0; border-top: 1px solid #e5e7eb;'>
|
643 |
-
<p
|
644 |
-
|
645 |
-
|
|
|
|
|
|
|
|
|
|
|
646 |
{html_body}
|
647 |
</div>
|
648 |
</div>
|
649 |
"""
|
650 |
return preview_html
|
651 |
|
652 |
-
async def
|
653 |
-
"""Wrapper to get AI suggestion for the chat."""
|
654 |
if issue_num is None or issue_num not in manager.issues:
|
655 |
return "Please select a valid issue first."
|
656 |
-
issue = manager.issues[issue_num]
|
657 |
-
issue_hash = manager._get_issue_hash(issue) # Use hash for caching
|
658 |
# Use cached_suggestion which handles the actual API call via lru_cache
|
659 |
-
|
660 |
-
|
661 |
-
|
662 |
-
|
663 |
-
|
664 |
-
|
|
|
|
|
|
|
|
|
665 |
if issue_num is None or issue_num not in manager.issues:
|
666 |
return "Please select a valid issue first."
|
667 |
result = await manager.generate_code_patch(issue_num, model_key)
|
668 |
if "error" in result:
|
669 |
return f"**Error generating patch:** {result['error']}"
|
670 |
else:
|
671 |
-
# Format for chat display
|
672 |
return f"""**Patch Suggestion from {result.get('model_used', model_key)}:**
|
673 |
|
674 |
**Explanation:**
|
675 |
{result.get('explanation', 'N/A')}
|
676 |
|
|
|
677 |
**Patch:**
|
678 |
```diff
|
679 |
{result.get('patch', 'N/A')}
|
@@ -695,19 +860,18 @@ def create_ui(manager: IssueManager):
|
|
695 |
with gr.Row():
|
696 |
github_token = gr.Textbox(label="GitHub Token (Optional)", type="password", info="Required for private repos or higher rate limits.", elem_id="github_token")
|
697 |
hf_token = gr.Textbox(label="Hugging Face Token", type="password", info="Required for AI model interactions.", elem_id="hf_token")
|
698 |
-
with gr.Column(scale=1):
|
699 |
-
# Removed language select as code editor handles it
|
700 |
model_select = gr.Dropdown(choices=list(HF_MODELS.keys()), value="Mistral-8x7B",
|
701 |
label="🤖 Select AI Model", info="Choose the AI for suggestions and patches.", elem_id="model_select")
|
702 |
crawl_btn = gr.Button("🛰️ Scan Repository Issues", variant="primary", icon="🔍", elem_id="crawl_btn")
|
703 |
-
status_output = gr.Textbox(label="Status", interactive=False, lines=1, placeholder="Status updates will appear here...", elem_id="status_output")
|
704 |
|
705 |
|
706 |
# --- Main Tabs ---
|
707 |
with gr.Tabs(elem_id="main-tabs"):
|
708 |
# --- Issue Board Tab ---
|
709 |
with gr.Tab("📋 Issue Board", id="board", elem_id="tab-board"):
|
710 |
-
with gr.Row():
|
711 |
with gr.Column(scale=3):
|
712 |
gr.Markdown("### Open Issues")
|
713 |
issue_list = gr.Dataframe(
|
@@ -716,15 +880,17 @@ def create_ui(manager: IssueManager):
|
|
716 |
interactive=True,
|
717 |
height=500,
|
718 |
wrap=True, # Wrap long titles
|
719 |
-
elem_id="issue_list_df"
|
|
|
|
|
720 |
)
|
721 |
-
with gr.Column(scale=2):
|
722 |
gr.Markdown("### Issue Severity")
|
723 |
stats_plot = gr.Plot(elem_id="stats_plot")
|
724 |
# Placeholder for collaborators - updated via JS
|
725 |
collab_status = gr.HTML("""
|
726 |
-
<div style="margin-top: 20px; border: 1px solid #e5e7eb; padding: 10px; border-radius: 8px;">
|
727 |
-
<h4 style="margin-bottom: 5px; color: #374151;">👥 Active Collaborators</h4>
|
728 |
<div id="collab-list" style="font-size: 0.9em; max-height: 100px; overflow-y: auto;">
|
729 |
Connecting...
|
730 |
</div>
|
@@ -735,8 +901,8 @@ def create_ui(manager: IssueManager):
|
|
735 |
with gr.Tab("💻 Resolution Studio", id="studio", elem_id="tab-studio"):
|
736 |
with gr.Row():
|
737 |
# Left Column: Issue Details & AI Tools
|
738 |
-
with gr.Column(scale=1):
|
739 |
-
gr.Markdown("### Selected Issue")
|
740 |
# Hidden number input to store selected issue ID
|
741 |
selected_issue_id = gr.Number(label="Selected Issue ID", visible=False, precision=0, elem_id="selected_issue_id")
|
742 |
issue_preview_html = gr.HTML(
|
@@ -750,23 +916,26 @@ def create_ui(manager: IssueManager):
|
|
750 |
# Add placeholders for other buttons if needed
|
751 |
# test_btn = gr.Button("🧪 Create Tests (Future)", icon="🔬", interactive=False)
|
752 |
# impact_btn = gr.Button("📊 Impact Analysis (Future)", icon="📈", interactive=False)
|
753 |
-
|
|
|
754 |
|
755 |
|
756 |
-
# Right Column: Code Editor
|
757 |
-
with gr.Column(scale=2):
|
758 |
gr.Markdown("### Collaborative Code Editor")
|
759 |
# Use the imported code_editor component
|
760 |
# We'll update its value dynamically when an issue is selected
|
|
|
761 |
code_edit_component = code_editor(
|
762 |
label="Code Editor",
|
763 |
-
#
|
764 |
-
|
765 |
-
|
766 |
-
|
767 |
-
|
768 |
)
|
769 |
# Hidden input to trigger code editor updates from server->client WS messages
|
|
|
770 |
code_editor_update_trigger = gr.Textbox(visible=False, elem_id="code-editor-update-trigger")
|
771 |
|
772 |
|
@@ -789,65 +958,88 @@ def create_ui(manager: IssueManager):
|
|
789 |
fn=manager.crawl_issues,
|
790 |
inputs=[repo_url, github_token, hf_token],
|
791 |
outputs=[issue_list, stats_plot, status_output],
|
792 |
-
api_name="crawl_issues" # For API access if needed
|
|
|
793 |
)
|
794 |
|
795 |
# 2. Issue Selection in Dataframe
|
796 |
async def handle_issue_select(evt: gr.SelectData):
|
797 |
"""Handles issue selection: updates preview, loads code (placeholder)."""
|
798 |
-
if evt.index[0] is None: # No row selected
|
|
|
799 |
return {
|
800 |
-
selected_issue_id: None,
|
801 |
issue_preview_html: "<p style='color: #6b7280;'>Select an issue from the table.</p>",
|
802 |
-
#
|
803 |
-
|
804 |
}
|
805 |
|
806 |
-
|
807 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
808 |
|
809 |
-
|
810 |
-
|
811 |
|
812 |
-
|
813 |
-
|
814 |
-
updates[issue_preview_html] = preview_html
|
815 |
|
816 |
-
|
817 |
-
|
818 |
-
|
819 |
-
|
820 |
-
|
821 |
-
|
822 |
-
|
823 |
-
|
824 |
-
|
825 |
-
updates[code_edit_component] = gr.update(value=files_content)
|
826 |
-
# --- End Placeholder ---
|
827 |
|
828 |
-
return updates
|
829 |
|
830 |
issue_list.select(
|
831 |
fn=handle_issue_select,
|
832 |
inputs=[], # Event data is passed automatically
|
833 |
-
outputs=[selected_issue_id, issue_preview_html, code_edit_component],
|
834 |
show_progress="minimal"
|
835 |
)
|
836 |
|
837 |
# 3. Suggest Resolution Button Click
|
838 |
suggest_btn.click(
|
839 |
-
fn=
|
840 |
inputs=[selected_issue_id, model_select],
|
841 |
-
outputs=[
|
842 |
-
api_name="suggest_resolution"
|
|
|
843 |
)
|
844 |
|
845 |
# 4. Generate Patch Button Click
|
846 |
patch_btn.click(
|
847 |
-
fn=
|
848 |
inputs=[selected_issue_id, model_select],
|
849 |
-
outputs=[
|
850 |
-
api_name="generate_patch"
|
|
|
851 |
)
|
852 |
|
853 |
# 5. Code Editor Change (User typing) -> Send update via WebSocket
|
@@ -865,25 +1057,38 @@ def create_ui(manager: IssueManager):
|
|
865 |
client_id = f"client_{hashlib.sha1(os.urandom(16)).hexdigest()[:8]}"
|
866 |
logger.info(f"Generated Client ID for WebSocket: {client_id}")
|
867 |
|
|
|
|
|
868 |
return f"""
|
869 |
<script>
|
870 |
-
// Ensure this runs only once
|
871 |
if (!window.collabWs) {{
|
872 |
console.log('Initializing WebSocket connection...');
|
873 |
-
|
874 |
-
|
875 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
876 |
|
877 |
window.collabWs = new WebSocket(wsUrl);
|
878 |
window.clientId = '{client_id}'; // Store client ID globally for this session
|
|
|
|
|
879 |
|
880 |
window.collabWs.onopen = function(event) {{
|
881 |
console.log('WebSocket connection established.');
|
882 |
-
//
|
883 |
-
window.
|
884 |
-
//
|
885 |
const collabListDiv = document.getElementById('collab-list');
|
886 |
-
if (collabListDiv) collabListDiv.innerHTML = 'Connected.';
|
887 |
}};
|
888 |
|
889 |
window.collabWs.onmessage = function(event) {{
|
@@ -891,61 +1096,54 @@ def create_ui(manager: IssueManager):
|
|
891 |
try {{
|
892 |
const data = JSON.parse(event.data);
|
893 |
|
|
|
894 |
if (data.type === 'collaboration_status') {{
|
895 |
const collabListDiv = document.getElementById('collab-list');
|
896 |
if (collabListDiv) {{
|
897 |
-
|
898 |
-
|
899 |
-
|
|
|
|
|
|
|
|
|
900 |
.join('');
|
901 |
}} else {{
|
902 |
-
collabListDiv.innerHTML = '
|
903 |
}}
|
904 |
}}
|
|
|
905 |
}} else if (data.type === 'code_update') {{
|
906 |
-
console.log('Received code update delta for issue:', data.issue_num);
|
907 |
-
//
|
908 |
-
|
909 |
-
|
910 |
-
|
911 |
-
|
912 |
-
|
913 |
-
|
914 |
-
|
915 |
-
|
916 |
-
// Manually dispatch an input event to ensure Gradio detects the change
|
917 |
-
triggerTextbox.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
918 |
-
console.log('Triggered Gradio update for code editor.');
|
919 |
-
|
920 |
-
// --- Ideal approach: Directly update Ace Editor ---
|
921 |
-
// This requires the code_editor component to expose its Ace instance
|
922 |
-
// or provide a JS function like `window.updateCodeEditor(issueNum, delta)`
|
923 |
-
/*
|
924 |
-
if (window.ace && window.aceEditors && window.aceEditors[data.issue_num]) {{
|
925 |
-
const editor = window.aceEditors[data.issue_num];
|
926 |
-
editor.getSession().getDocument().applyDeltas([JSON.parse(data.delta)]);
|
927 |
-
console.log('Applied delta directly to Ace editor for issue:', data.issue_num);
|
928 |
-
}} else {{
|
929 |
-
console.warn('Ace editor instance not found for issue:', data.issue_num);
|
930 |
}}
|
931 |
-
*/
|
932 |
}} else {{
|
933 |
-
console.
|
934 |
}}
|
|
|
935 |
}} else if (data.type === 'issues_updated') {{
|
936 |
console.log('Received issues updated notification.');
|
937 |
-
//
|
938 |
-
|
939 |
-
const statusBar = document.getElementById('status_output').querySelector('textarea');
|
940 |
if (statusBar) {{
|
941 |
-
|
|
|
|
|
942 |
statusBar.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
943 |
}}
|
944 |
-
//
|
945 |
}}
|
946 |
|
947 |
}} catch (e) {{
|
948 |
-
console.error('Failed to parse WebSocket message or update UI:', e);
|
949 |
}}
|
950 |
}};
|
951 |
|
@@ -953,7 +1151,8 @@ def create_ui(manager: IssueManager):
|
|
953 |
console.warn('WebSocket connection closed:', event.code, event.reason);
|
954 |
const collabListDiv = document.getElementById('collab-list');
|
955 |
if (collabListDiv) collabListDiv.innerHTML = '<span style="color: red;">Disconnected</span>';
|
956 |
-
// Implement reconnection logic
|
|
|
957 |
}};
|
958 |
|
959 |
window.collabWs.onerror = function(error) {{
|
@@ -962,99 +1161,128 @@ def create_ui(manager: IssueManager):
|
|
962 |
if (collabListDiv) collabListDiv.innerHTML = '<span style="color: red;">Connection Error</span>';
|
963 |
}};
|
964 |
|
965 |
-
// Function to send messages
|
966 |
window.sendWsMessage = function(message) {{
|
967 |
if (window.collabWs && window.collabWs.readyState === WebSocket.OPEN) {{
|
968 |
window.collabWs.send(JSON.stringify(message));
|
969 |
}} else {{
|
970 |
-
console.error('WebSocket not connected. Cannot send message
|
971 |
}}
|
972 |
}};
|
973 |
|
974 |
// --- JS Integration with Code Editor Component ---
|
975 |
-
//
|
976 |
-
//
|
977 |
-
// We need to:
|
978 |
-
// 1. Get the editor instance(s).
|
979 |
-
// 2. Attach a listener to its 'change' event (which provides deltas).
|
980 |
-
// 3. When a change occurs, send the delta via `sendWsMessage`.
|
981 |
-
|
982 |
-
// Example assuming Ace Editor and the component stores instances:
|
983 |
function setupCodeEditorListener() {{
|
984 |
-
//
|
985 |
-
|
986 |
-
|
987 |
-
|
988 |
-
|
989 |
-
|
990 |
-
let aceEditor;
|
991 |
-
if (window.ace && editorElement) {{
|
992 |
-
// Try common ways Ace is attached
|
993 |
-
aceEditor = window.ace.edit(editorElement.querySelector('.ace_editor')); // Common pattern
|
994 |
-
// Or maybe the component stores it globally?
|
995 |
-
// aceEditor = window.activeAceEditor;
|
996 |
-
}}
|
997 |
|
998 |
-
|
999 |
-
|
1000 |
-
|
1001 |
-
|
1002 |
-
|
1003 |
-
|
1004 |
-
const issueIdElem = document.getElementById('selected_issue_id').querySelector('input');
|
1005 |
-
const currentIssueId = issueIdElem ? parseInt(issueIdElem.value, 10) : null;
|
1006 |
-
|
1007 |
-
if (currentIssueId !== null && !isNaN(currentIssueId)) {{
|
1008 |
-
window.sendWsMessage({{
|
1009 |
-
type: 'code_update',
|
1010 |
-
issue_num: currentIssueId,
|
1011 |
-
delta: JSON.stringify(delta), // Send delta as JSON string
|
1012 |
-
clientId: window.clientId
|
1013 |
-
}});
|
1014 |
-
}} else {{
|
1015 |
-
console.warn('No valid issue selected, cannot send code update.');
|
1016 |
-
}}
|
1017 |
-
}}
|
1018 |
-
}});
|
1019 |
-
}} else {{
|
1020 |
-
console.warn('Could not find Ace Editor instance to attach listener. Collaboration may not work.');
|
1021 |
-
// Retry after a delay?
|
1022 |
-
// setTimeout(setupCodeEditorListener, 2000);
|
1023 |
-
}}
|
1024 |
-
}}, 1500); // Wait 1.5 seconds for Gradio/Ace to initialize
|
1025 |
-
}}
|
1026 |
|
1027 |
-
|
1028 |
-
|
1029 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1030 |
|
1031 |
-
|
1032 |
-
|
1033 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1034 |
for(const mutation of mutationsList) {{
|
1035 |
-
if (mutation.type === 'childList'
|
1036 |
-
// Check if the editor element
|
1037 |
if (document.querySelector('#code_editor_component .ace_editor')) {{
|
1038 |
-
console.log("
|
1039 |
-
|
1040 |
-
|
|
|
|
|
|
|
|
|
|
|
1041 |
}}
|
1042 |
}}
|
1043 |
}}
|
1044 |
}});
|
1045 |
-
|
1046 |
-
|
1047 |
-
|
1048 |
-
|
|
|
|
|
|
|
|
|
|
|
1049 |
|
1050 |
|
1051 |
}} else {{
|
1052 |
-
console.log('WebSocket connection already initialized.');
|
1053 |
}}
|
1054 |
</script>
|
1055 |
"""
|
1056 |
|
1057 |
-
# Inject the JavaScript into the Gradio app
|
1058 |
app.load(_js=web_socket_js(WS_PORT), fn=None, inputs=None, outputs=None)
|
1059 |
|
1060 |
return app
|
@@ -1065,20 +1293,22 @@ async def handle_ws_connection(websocket: websockets.WebSocketServerProtocol, pa
|
|
1065 |
"""Handles incoming WebSocket connections and messages."""
|
1066 |
client_id = None # Initialize client_id for this connection
|
1067 |
manager.ws_clients.append(websocket)
|
1068 |
-
logger.info(f"WebSocket client connected: {websocket.remote_address}")
|
1069 |
try:
|
1070 |
async for message in websocket:
|
1071 |
try:
|
1072 |
data = json.loads(message)
|
1073 |
msg_type = data.get("type")
|
1074 |
-
logger.debug(f"Received WS message: {data}") # Log received message content
|
1075 |
|
1076 |
if msg_type == "join":
|
1077 |
client_id = data.get("clientId", f"anon_{websocket.id}")
|
1078 |
setattr(websocket, 'client_id', client_id) # Associate ID with socket object
|
1079 |
manager.collaborators[client_id] = {"name": client_id, "status": "Connected"} # Add to collaborators
|
1080 |
logger.info(f"Client {client_id} joined.")
|
1081 |
-
#
|
|
|
|
|
1082 |
|
1083 |
elif msg_type == "code_update":
|
1084 |
issue_num = data.get("issue_num")
|
@@ -1096,77 +1326,124 @@ async def handle_ws_connection(websocket: websockets.WebSocketServerProtocol, pa
|
|
1096 |
if sender_id and sender_id in manager.collaborators:
|
1097 |
manager.collaborators[sender_id]["status"] = status
|
1098 |
logger.info(f"Client {sender_id} status updated: {status}")
|
1099 |
-
#
|
|
|
|
|
1100 |
|
1101 |
else:
|
1102 |
-
logger.warning(f"Unknown WebSocket message type received: {msg_type}")
|
1103 |
|
1104 |
except json.JSONDecodeError:
|
1105 |
-
logger.error(f"Received invalid JSON over WebSocket: {message}")
|
1106 |
except Exception as e:
|
1107 |
-
logger.exception(f"Error processing WebSocket message: {e}")
|
1108 |
|
1109 |
except ConnectionClosed as e:
|
1110 |
-
logger.info(f"WebSocket client
|
1111 |
except Exception as e:
|
1112 |
-
logger.exception(f"Unexpected error in WebSocket handler: {e}")
|
1113 |
finally:
|
1114 |
-
logger.info(f"Cleaning up connection for client {client_id
|
1115 |
-
|
1116 |
-
|
1117 |
-
|
1118 |
-
|
1119 |
-
|
1120 |
|
1121 |
async def start_websocket_server(manager: IssueManager, port: int):
|
1122 |
"""Starts the WebSocket server."""
|
1123 |
# Pass manager instance to the connection handler factory
|
1124 |
-
|
1125 |
-
|
1126 |
-
|
1127 |
-
await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1128 |
|
1129 |
def run_webhook_server(manager: IssueManager, port: int):
|
1130 |
"""Starts the HTTP webhook server in a separate thread."""
|
1131 |
WebhookHandler.manager_instance = manager # Pass manager instance to the class
|
1132 |
-
server_address = ("", port)
|
1133 |
-
|
1134 |
-
|
1135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1136 |
|
1137 |
|
1138 |
# ========== Main Execution ==========
|
1139 |
if __name__ == "__main__":
|
1140 |
# --- Setup ---
|
1141 |
manager = IssueManager()
|
|
|
|
|
1142 |
|
1143 |
# --- Start Background Servers ---
|
1144 |
-
# 1. Webhook Server (HTTP)
|
1145 |
webhook_thread = threading.Thread(target=run_webhook_server, args=(manager, WEBHOOK_PORT), daemon=True)
|
1146 |
webhook_thread.start()
|
1147 |
|
1148 |
-
# 2. WebSocket Server (
|
1149 |
-
# We need to run the WebSocket server and the collaborator status broadcast
|
1150 |
-
# within an asyncio event loop.
|
1151 |
async def main_async_tasks():
|
1152 |
# Start the periodic broadcast task
|
1153 |
broadcast_task = asyncio.create_task(manager.broadcast_collaboration_status())
|
1154 |
# Start the WebSocket server
|
1155 |
websocket_server_task = asyncio.create_task(start_websocket_server(manager, WS_PORT))
|
1156 |
-
|
|
|
1157 |
|
1158 |
-
#
|
1159 |
-
|
1160 |
-
asyncio_thread.start()
|
1161 |
-
|
1162 |
-
# --- Create and Launch Gradio App ---
|
1163 |
app = create_ui(manager)
|
|
|
|
|
|
|
|
|
|
|
1164 |
app.launch(
|
1165 |
# share=True, # Enable for public access (use with caution)
|
1166 |
-
server_name="0.0.0.0", # Bind to all interfaces for accessibility
|
1167 |
server_port=7860, # Default Gradio port
|
1168 |
-
favicon_path="[https://huggingface.co/front/assets/huggingface_logo-noborder.svg](https://huggingface.co/front/assets/huggingface_logo-noborder.svg)"
|
|
|
|
|
1169 |
)
|
1170 |
|
1171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1172 |
|
|
|
94 |
if event == 'issues' and WebhookHandler.manager_instance:
|
95 |
action = payload.get('action')
|
96 |
logger.info(f"Issue action: {action}")
|
97 |
+
if action in ['opened', 'reopened', 'closed', 'assigned', 'edited']: # Handle edited issues too
|
98 |
# Ensure the event loop is running in the webhook thread if needed
|
99 |
+
# Get the loop associated with the asyncio thread
|
100 |
+
loop = asyncio.get_event_loop_policy().get_event_loop()
|
101 |
+
if loop.is_running():
|
102 |
+
asyncio.run_coroutine_threadsafe(
|
103 |
+
WebhookHandler.manager_instance.handle_webhook_event(event, action, payload),
|
104 |
+
loop
|
105 |
+
)
|
106 |
+
else:
|
107 |
+
logger.error("Asyncio event loop is not running in the target thread for webhook.")
|
108 |
+
|
109 |
elif event == 'ping':
|
110 |
logger.info("Received GitHub webhook ping.")
|
111 |
else:
|
|
|
135 |
self.issue_clusters: Dict[int, List[int]] = {} # Store clusters: {cluster_id: [issue_index1, issue_index2]}
|
136 |
self.issue_list_for_clustering: List[dict] = [] # Store issues in list order for clustering index mapping
|
137 |
# self._init_local_models() # Consider lazy loading or conditional loading
|
138 |
+
self.ws_clients: List[websockets.WebSocketServerProtocol] = [] # Use WebSocketServerProtocol
|
139 |
self.code_editors: Dict[int, OTCodeEditor] = {} # Store code editors for each issue
|
140 |
+
self.main_loop = asyncio.get_event_loop() # Store ref to main loop if needed elsewhere
|
141 |
|
142 |
# Placeholder for local model initialization - implement actual loading if needed
|
143 |
def _init_local_models(self):
|
|
|
152 |
return hashlib.md5(content.encode()).hexdigest()
|
153 |
|
154 |
@lru_cache(maxsize=100)
|
155 |
+
async def cached_suggestion(self, issue_hash: str, model_key: str) -> str:
|
156 |
# Find the issue corresponding to the hash (inefficient, improve if needed)
|
157 |
found_issue = None
|
158 |
for issue in self.issues.values():
|
|
|
161 |
break
|
162 |
if not found_issue:
|
163 |
return "Error: Issue not found for the given hash."
|
164 |
+
if model_key not in HF_MODELS:
|
165 |
+
return f"Error: Invalid model key: {model_key}"
|
166 |
+
|
167 |
+
logger.info(f"Cache miss or first request for issue hash {issue_hash}. Requesting suggestion from {model_key}.")
|
168 |
+
# Pass the actual issue dict and model_key to suggest_resolution
|
169 |
+
return await self.suggest_resolution(found_issue, model_key)
|
170 |
|
|
|
|
|
171 |
|
172 |
async def handle_webhook_event(self, event: str, action: str, payload: dict):
|
173 |
logger.info(f"Processing webhook event: {event}, action: {action}")
|
|
|
181 |
logger.warning("Webhook issue data missing 'number'.")
|
182 |
return
|
183 |
|
184 |
+
needs_update = False
|
185 |
if action == 'closed':
|
186 |
logger.info(f"Removing closed issue {issue_number} from active list.")
|
187 |
+
if self.issues.pop(issue_number, None):
|
188 |
+
needs_update = True
|
189 |
# Optionally remove associated editor, etc.
|
190 |
self.code_editors.pop(issue_number, None)
|
191 |
elif action in ['opened', 'reopened', 'edited']: # Handle edited issues too
|
192 |
logger.info(f"Adding/Updating issue {issue_number} from webhook.")
|
193 |
+
self.issues[issue_number] = self._process_issue_data(issue_data) # Use helper
|
194 |
+
needs_update = True
|
195 |
# Potentially trigger re-clustering or update specific issue details
|
196 |
elif action == 'assigned':
|
197 |
logger.info(f"Issue {issue_number} assigned to {payload.get('assignee', {}).get('login', 'N/A')}")
|
198 |
+
if issue_number in self.issues:
|
199 |
+
self.issues[issue_number] = self._process_issue_data(issue_data) # Update issue data
|
200 |
+
needs_update = True
|
201 |
+
else: # Issue might not be in our list if it wasn't open initially
|
202 |
+
self.issues[issue_number] = self._process_issue_data(issue_data)
|
203 |
+
needs_update = True
|
204 |
+
|
205 |
else:
|
206 |
logger.info(f"Ignoring action '{action}' for issue {issue_number}.")
|
207 |
|
208 |
+
# Trigger a UI update notification via WebSocket if data changed
|
209 |
+
if needs_update:
|
210 |
+
await self.broadcast_issue_update()
|
211 |
+
|
212 |
+
def _process_issue_data(self, issue_data: dict) -> dict:
|
213 |
+
"""Helper to structure issue data consistently."""
|
214 |
+
return {
|
215 |
+
"id": issue_data['number'],
|
216 |
+
"title": issue_data.get('title', 'No Title'),
|
217 |
+
"body": issue_data.get('body', ''),
|
218 |
+
"state": issue_data.get('state', 'unknown'),
|
219 |
+
"labels": [label['name'] for label in issue_data.get('labels', [])],
|
220 |
+
"assignee": issue_data.get('assignee', {}).get('login') if issue_data.get('assignee') else None,
|
221 |
+
"url": issue_data.get('html_url', '#')
|
222 |
+
# Add other relevant fields if needed
|
223 |
+
}
|
224 |
|
225 |
async def crawl_issues(self, repo_url: str, github_token: str, hf_token: str) -> Tuple[List[List], go.Figure, str]:
|
226 |
"""
|
227 |
Crawls issues, updates internal state, performs clustering, and returns data for UI update.
|
228 |
"""
|
229 |
+
if not repo_url or not hf_token: # GitHub token is optional for public repos
|
230 |
+
return [], go.Figure(), "Error: Repository URL and HF Token are required."
|
231 |
|
232 |
logger.info(f"Starting issue crawl for {repo_url}")
|
233 |
self.repo_url = repo_url
|
|
|
241 |
logger.error(f"Invalid GitHub URL format: {repo_url}")
|
242 |
return [], go.Figure(), "Error: Invalid GitHub URL format. Use https://github.com/owner/repo"
|
243 |
owner, repo_name = match.groups()
|
244 |
+
api_url = f"{GITHUB_API}/{owner}/{repo_name}/issues?state=open&per_page=100" # Fetch only open issues, max 100
|
245 |
|
246 |
headers = {
|
|
|
247 |
"Accept": "application/vnd.github.v3+json"
|
248 |
}
|
249 |
+
if github_token:
|
250 |
+
headers["Authorization"] = f"token {github_token}"
|
251 |
+
|
252 |
|
253 |
try:
|
254 |
+
all_issues_data = []
|
255 |
+
page = 1
|
256 |
+
logger.info(f"Fetching issues from {api_url}...")
|
257 |
async with aiohttp.ClientSession(headers=headers) as session:
|
258 |
+
while True:
|
259 |
+
paginated_url = f"{api_url}&page={page}"
|
260 |
+
async with session.get(paginated_url) as response:
|
261 |
+
response.raise_for_status() # Raise exception for bad status codes
|
262 |
+
issues_page_data = await response.json()
|
263 |
+
if not issues_page_data: # No more issues on this page
|
264 |
+
break
|
265 |
+
logger.info(f"Fetched page {page} with {len(issues_page_data)} issues.")
|
266 |
+
all_issues_data.extend(issues_page_data)
|
267 |
+
# Check Link header for next page (more robust pagination)
|
268 |
+
if 'next' not in response.headers.get('Link', ''):
|
269 |
+
break
|
270 |
+
page += 1
|
271 |
+
|
272 |
+
|
273 |
+
logger.info(f"Total issues fetched: {len(all_issues_data)}")
|
274 |
+
for issue_data in all_issues_data:
|
275 |
+
issue_number = issue_data['number']
|
276 |
+
self.issues[issue_number] = self._process_issue_data(issue_data) # Use helper
|
277 |
|
278 |
if not self.issues:
|
279 |
logger.info("No open issues found.")
|
|
|
319 |
error_msg = f"Error: Repository not found at {repo_url}. Check the URL."
|
320 |
elif e.status == 401:
|
321 |
error_msg = "Error: Invalid GitHub token or insufficient permissions."
|
322 |
+
elif e.status == 403:
|
323 |
+
error_msg = "Error: GitHub API rate limit exceeded or forbidden access. Try adding a GitHub token."
|
324 |
return [], go.Figure(), error_msg
|
325 |
except GitCommandError as e:
|
326 |
+
logger.error(f"Git clone error: {e}") # Should not happen if not cloning
|
327 |
+
return [], go.Figure(), f"Error related to Git: {e}"
|
328 |
except Exception as e:
|
329 |
logger.exception(f"An unexpected error occurred during issue crawl: {e}") # Log full traceback
|
330 |
return [], go.Figure(), f"An unexpected error occurred: {e}"
|
|
|
339 |
|
340 |
def _generate_stats_plot(self, severity_counts: Dict[str, int]) -> go.Figure:
|
341 |
"""Generates a Plotly bar chart for issue severity distribution."""
|
342 |
+
# Filter out severities with 0 counts
|
343 |
+
filtered_counts = {k: v for k, v in severity_counts.items() if v > 0}
|
344 |
+
if not filtered_counts:
|
345 |
+
# Return an empty figure with a message if no issues found
|
346 |
+
fig = go.Figure()
|
347 |
+
fig.update_layout(
|
348 |
+
title="Issue Severity Distribution",
|
349 |
+
xaxis = {"visible": False},
|
350 |
+
yaxis = {"visible": False},
|
351 |
+
annotations = [{
|
352 |
+
"text": "No issues found to display statistics.",
|
353 |
+
"xref": "paper",
|
354 |
+
"yref": "paper",
|
355 |
+
"showarrow": False,
|
356 |
+
"font": {"size": 16}
|
357 |
+
}],
|
358 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
359 |
+
paper_bgcolor='rgba(0,0,0,0)'
|
360 |
+
)
|
361 |
+
return fig
|
362 |
+
|
363 |
+
severities = list(filtered_counts.keys())
|
364 |
+
counts = list(filtered_counts.values())
|
365 |
|
366 |
fig = px.bar(
|
367 |
x=severities,
|
|
|
375 |
'Medium': '#FACC15', # Yellow
|
376 |
'Low': '#84CC16', # Lime
|
377 |
'Unknown': '#6B7280' # Gray
|
378 |
+
},
|
379 |
+
text=counts # Display counts on bars
|
380 |
)
|
381 |
fig.update_layout(
|
382 |
+
xaxis_title=None, # Cleaner look
|
383 |
yaxis_title="Number of Issues",
|
|
|
384 |
plot_bgcolor='rgba(0,0,0,0)', # Transparent background
|
385 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
386 |
+
showlegend=False,
|
387 |
+
xaxis={'categoryorder':'array', 'categoryarray':['Critical', 'High', 'Medium', 'Low', 'Unknown']} # Order bars
|
388 |
)
|
389 |
+
fig.update_traces(textposition='outside')
|
390 |
return fig
|
391 |
|
392 |
async def _cluster_similar_issues(self):
|
|
|
398 |
|
399 |
logger.info("Generating embeddings for clustering...")
|
400 |
try:
|
401 |
+
# Combine title and body for embedding generation
|
402 |
+
texts_to_embed = [f"{i.get('title','')} - {i.get('body','')[:500]}" for i in self.issue_list_for_clustering] # Limit body length
|
403 |
+
embeddings = await self._generate_embeddings(texts_to_embed)
|
404 |
+
|
405 |
+
if embeddings is None or not isinstance(embeddings, list) or len(embeddings) != len(self.issue_list_for_clustering):
|
406 |
+
logger.error(f"Failed to generate valid embeddings. Expected {len(self.issue_list_for_clustering)}, got {len(embeddings) if embeddings else 'None'}.")
|
407 |
self.issue_clusters = {}
|
408 |
return
|
409 |
|
|
|
416 |
clusters = clusterer.fit_predict(embeddings)
|
417 |
|
418 |
self.issue_clusters = {}
|
419 |
+
noise_count = 0
|
420 |
for i, cluster_id in enumerate(clusters):
|
421 |
if cluster_id == -1: # HDBSCAN uses -1 for noise points
|
422 |
+
noise_count += 1
|
423 |
continue # Skip noise points
|
424 |
if cluster_id not in self.issue_clusters:
|
425 |
self.issue_clusters[cluster_id] = []
|
426 |
self.issue_clusters[cluster_id].append(i) # Store original index
|
427 |
|
428 |
+
logger.info(f"Clustering complete. Found {len(self.issue_clusters)} clusters with {noise_count} noise points.")
|
429 |
|
430 |
except Exception as e:
|
431 |
logger.exception(f"Error during issue clustering: {e}")
|
|
|
443 |
headers = {"Authorization": f"Bearer {self.hf_token}"}
|
444 |
|
445 |
logger.info(f"Requesting embeddings from {api_url} for {len(texts)} texts.")
|
446 |
+
# Add timeout to the request
|
447 |
+
timeout = aiohttp.ClientTimeout(total=60) # 60 seconds timeout
|
448 |
+
async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session:
|
449 |
try:
|
450 |
+
# Add wait_for_model=True if using serverless inference endpoints
|
451 |
+
payload = {"inputs": texts, "options": {"wait_for_model": True}}
|
452 |
+
response = await session.post(api_url, json=payload)
|
453 |
response.raise_for_status()
|
454 |
result = await response.json()
|
455 |
# Check if the result is a list of embeddings (floats)
|
456 |
if isinstance(result, list) and all(isinstance(emb, list) for emb in result):
|
457 |
logger.info(f"Successfully received {len(result)} embeddings.")
|
458 |
return result
|
459 |
+
elif isinstance(result, dict) and 'error' in result:
|
460 |
+
logger.error(f"HF Inference API returned an error: {result['error']}")
|
461 |
+
return None
|
462 |
else:
|
463 |
logger.error(f"Unexpected embedding format received: {type(result)}. Full response: {result}")
|
464 |
return None
|
465 |
except aiohttp.ClientResponseError as e:
|
466 |
logger.error(f"HF Inference API request failed: {e.status} {e.message}")
|
467 |
+
try:
|
468 |
+
error_body = await e.response.text()
|
469 |
+
logger.error(f"Response body: {error_body[:500]}") # Log first 500 chars
|
470 |
+
except Exception as read_err:
|
471 |
+
logger.error(f"Could not read error response body: {read_err}")
|
472 |
+
return None
|
473 |
+
except asyncio.TimeoutError:
|
474 |
+
logger.error(f"HF Inference API request timed out after {timeout.total} seconds.")
|
475 |
return None
|
476 |
except Exception as e:
|
477 |
logger.exception(f"An unexpected error occurred during embedding generation: {e}")
|
|
|
490 |
model_id = HF_MODELS[model_key]
|
491 |
logger.info(f"Generating patch for issue {issue_number} using model {model_id}")
|
492 |
|
493 |
+
# --- Context Gathering (Placeholder) ---
|
494 |
+
context = "Context gathering not implemented. Provide relevant code snippets in the issue description if possible."
|
|
|
|
|
|
|
|
|
495 |
# context = await self._get_code_context(issue_number) # Uncomment if implemented
|
496 |
|
497 |
# --- Prompt Engineering ---
|
498 |
+
prompt = f"""You are an expert programmer analyzing a GitHub issue. Your task is to generate a code patch in standard `diff` format to fix the described problem.
|
|
|
|
|
|
|
499 |
|
500 |
+
## Issue Details:
|
501 |
+
### Title: {issue.get('title', 'N/A')}
|
502 |
+
### Body:
|
503 |
{issue.get('body', 'N/A')}
|
504 |
+
### Labels: {', '.join(issue.get('labels', []))}
|
505 |
|
506 |
## Relevant Code Context (if available):
|
507 |
{context}
|
508 |
|
509 |
## Instructions:
|
510 |
+
1. Carefully analyze the issue description and context.
|
511 |
+
2. Identify the specific code changes required to resolve the issue.
|
512 |
+
3. Generate a patch containing *only* the necessary code modifications.
|
513 |
+
4. Format the patch strictly according to the standard `diff` format, enclosed in a ```diff ... ``` block.
|
514 |
+
5. Provide a brief explanation *before* the diff block explaining the reasoning behind the changes.
|
515 |
+
6. If a patch cannot be reasonably determined from the provided information, state that clearly instead of generating an incorrect patch.
|
516 |
|
517 |
## Patch Suggestion:
|
518 |
"""
|
|
|
523 |
payload = {
|
524 |
"inputs": prompt,
|
525 |
"parameters": { # Adjust parameters as needed
|
526 |
+
"max_new_tokens": 1536, # Increased max tokens for potentially larger patches
|
527 |
+
"temperature": 0.2, # Low temperature for deterministic code generation
|
528 |
"return_full_text": False, # Only get the generated part
|
529 |
+
"do_sample": False, # Turn off sampling for more deterministic output with low temp
|
530 |
+
# "top_p": 0.9, # Less relevant when do_sample=False
|
531 |
+
},
|
532 |
+
"options": {"wait_for_model": True} # For serverless endpoints
|
533 |
}
|
534 |
+
timeout = aiohttp.ClientTimeout(total=120) # 2 minutes timeout for generation
|
535 |
|
536 |
try:
|
537 |
+
async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session:
|
538 |
async with session.post(api_url, json=payload) as response:
|
539 |
response.raise_for_status()
|
540 |
result = await response.json()
|
541 |
+
if result and isinstance(result, list) and 'generated_text' in result[0]:
|
542 |
generated_text = result[0].get('generated_text', '')
|
543 |
logger.info(f"Received patch suggestion from {model_id}")
|
544 |
+
# Improved extraction of diff block
|
545 |
diff_match = re.search(r"```diff\n(.*?)```", generated_text, re.DOTALL)
|
546 |
explanation = generated_text.split("```diff")[0].strip()
|
547 |
+
if not explanation: # Handle cases where explanation might be missing
|
548 |
+
explanation = "No explanation provided."
|
549 |
+
|
550 |
+
if diff_match:
|
551 |
+
patch = diff_match.group(1).strip()
|
552 |
+
# Basic validation: check if patch contains diff markers like + or -
|
553 |
+
if not re.search(r'^\s*[+-]', patch, re.MULTILINE):
|
554 |
+
patch = f"AI generated response, but no standard diff markers (+/-) found:\n---\n{patch}\n---"
|
555 |
+
logger.warning("Generated patch lacks standard diff markers.")
|
556 |
+
else:
|
557 |
+
patch = "No diff block found in the AI response."
|
558 |
+
logger.warning("No diff block found in patch suggestion response.")
|
559 |
+
|
560 |
|
561 |
return {
|
562 |
"explanation": explanation,
|
563 |
"patch": patch,
|
564 |
"model_used": model_id
|
565 |
}
|
566 |
+
elif isinstance(result, dict) and 'error' in result:
|
567 |
+
logger.error(f"HF Inference API returned an error for patch generation: {result['error']}")
|
568 |
+
return {"error": f"AI model returned an error: {result['error']}"}
|
569 |
else:
|
570 |
+
logger.error(f"Unexpected response format from {model_id} for patch: {result}")
|
571 |
return {"error": "Received unexpected response format from AI model."}
|
572 |
except aiohttp.ClientResponseError as e:
|
573 |
logger.error(f"HF Inference API request failed for patch generation: {e.status} {e.message}")
|
574 |
+
error_body = await e.response.text()
|
575 |
+
logger.error(f"Response body: {error_body[:500]}")
|
576 |
return {"error": f"AI model request failed ({e.status}). Check model availability and HF token."}
|
577 |
+
except asyncio.TimeoutError:
|
578 |
+
logger.error(f"HF Inference API request for patch generation timed out after {timeout.total} seconds.")
|
579 |
+
return {"error": "AI model request timed out."}
|
580 |
except Exception as e:
|
581 |
logger.exception(f"Error generating code patch: {e}")
|
582 |
return {"error": f"An unexpected error occurred: {e}"}
|
|
|
607 |
model_id = HF_MODELS[model_key]
|
608 |
logger.info(f"Requesting resolution suggestion for issue {issue.get('id','N/A')} using {model_id}")
|
609 |
|
610 |
+
prompt = f"""Analyze the following GitHub issue and provide a concise, step-by-step suggestion on how to resolve it. Focus on the technical steps required. Be clear and actionable.
|
611 |
|
612 |
+
## Issue Details:
|
613 |
+
### Title: {issue.get('title', 'N/A')}
|
614 |
+
### Body:
|
615 |
{issue.get('body', 'N/A')}
|
616 |
+
### Labels: {', '.join(issue.get('labels', []))}
|
617 |
|
618 |
## Suggested Resolution Steps:
|
619 |
+
1. **Understand the Root Cause:** [Briefly explain the likely cause based on the description]
|
620 |
+
2. **Identify Files:** [Suggest specific files or modules likely involved]
|
621 |
+
3. **Implement Changes:** [Describe the necessary code modifications or additions]
|
622 |
+
4. **Test Thoroughly:** [Mention specific testing approaches needed]
|
623 |
+
5. **Create Pull Request:** [Standard final step]
|
624 |
+
|
625 |
+
Provide details for steps 1-4 based on the issue.
|
626 |
"""
|
627 |
api_url = f"{HF_INFERENCE_API}/{model_id}"
|
628 |
headers = {"Authorization": f"Bearer {self.hf_token}"}
|
629 |
payload = {
|
630 |
"inputs": prompt,
|
631 |
"parameters": {
|
632 |
+
"max_new_tokens": 768, # Increased token limit
|
633 |
+
"temperature": 0.6, # Slightly lower temp for more focused suggestions
|
634 |
"return_full_text": False,
|
635 |
"do_sample": True,
|
636 |
"top_p": 0.95,
|
637 |
+
},
|
638 |
+
"options": {"wait_for_model": True} # For serverless endpoints
|
639 |
}
|
640 |
+
timeout = aiohttp.ClientTimeout(total=90) # 90 seconds timeout
|
641 |
|
642 |
try:
|
643 |
+
async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session:
|
644 |
async with session.post(api_url, json=payload) as response:
|
645 |
response.raise_for_status()
|
646 |
result = await response.json()
|
647 |
+
if result and isinstance(result, list) and 'generated_text' in result[0]:
|
648 |
suggestion = result[0].get('generated_text', 'No suggestion generated.')
|
649 |
logger.info(f"Received suggestion from {model_id}")
|
650 |
return suggestion.strip()
|
651 |
+
elif isinstance(result, dict) and 'error' in result:
|
652 |
+
logger.error(f"HF Inference API returned an error for suggestion: {result['error']}")
|
653 |
+
return f"Error: AI model returned an error: {result['error']}"
|
654 |
else:
|
655 |
logger.error(f"Unexpected response format from {model_id} for suggestion: {result}")
|
656 |
return "Error: Received unexpected response format from AI model."
|
657 |
except aiohttp.ClientResponseError as e:
|
658 |
logger.error(f"HF Inference API request failed for suggestion: {e.status} {e.message}")
|
659 |
+
error_body = await e.response.text()
|
660 |
+
logger.error(f"Response body: {error_body[:500]}")
|
661 |
return f"Error: AI model request failed ({e.status}). Check model availability and HF token."
|
662 |
+
except asyncio.TimeoutError:
|
663 |
+
logger.error(f"HF Inference API request for suggestion timed out after {timeout.total} seconds.")
|
664 |
+
return "Error: AI model request timed out."
|
665 |
except Exception as e:
|
666 |
logger.exception(f"Error suggesting resolution: {e}")
|
667 |
return f"An unexpected error occurred: {e}"
|
|
|
675 |
if not self.ws_clients:
|
676 |
continue
|
677 |
|
678 |
+
# Create payload within the loop to get current status
|
679 |
status_payload = json.dumps({
|
680 |
"type": "collaboration_status",
|
681 |
"collaborators": self.collaborators
|
682 |
})
|
683 |
+
if self.collaborators: # Only broadcast if there's someone to report
|
684 |
+
logger.debug(f"Broadcasting status: {status_payload}")
|
685 |
+
|
686 |
# Use asyncio.gather to send concurrently, handling potential errors
|
687 |
results = await asyncio.gather(
|
688 |
*[client.send(status_payload) for client in self.ws_clients],
|
689 |
return_exceptions=True # Don't let one failed send stop others
|
690 |
)
|
691 |
# Log any errors that occurred during broadcast
|
692 |
+
active_clients = []
|
693 |
for i, result in enumerate(results):
|
694 |
+
client = self.ws_clients[i]
|
695 |
if isinstance(result, Exception):
|
696 |
+
logger.warning(f"Failed to send status to client {getattr(client, 'client_id', client.remote_address)}: {result}. Removing client.")
|
697 |
+
# Schedule removal in the main loop to avoid modifying list while iterating
|
698 |
+
# self.main_loop.call_soon_threadsafe(self.remove_ws_client, client) # Requires main_loop ref
|
699 |
+
else:
|
700 |
+
active_clients.append(client)
|
701 |
+
# self.ws_clients = active_clients # Update list - Careful with concurrency here
|
702 |
|
703 |
|
704 |
async def handle_code_editor_update(self, issue_num: int, delta: str, client_id: str):
|
705 |
"""Applies a delta from one client and broadcasts it to others."""
|
706 |
if issue_num not in self.code_editors:
|
707 |
+
# Initialize editor if it doesn't exist (e.g., first edit)
|
708 |
+
# This requires knowing the initial content, which might be tricky.
|
709 |
+
# For now, log a warning. A better approach might be needed.
|
710 |
+
logger.warning(f"Received code update for non-existent editor instance for issue {issue_num}. Ignoring.")
|
711 |
+
# Alternative: self.code_editors[issue_num] = OTCodeEditor(initial_content="...")
|
712 |
+
return
|
713 |
|
714 |
try:
|
715 |
# Apply the delta to the server-side authoritative state
|
716 |
+
# Assuming apply_delta modifies the internal state correctly
|
717 |
+
parsed_delta = json.loads(delta) # Parse delta once
|
718 |
+
self.code_editors[issue_num].apply_delta(parsed_delta)
|
719 |
logger.info(f"Applied delta for issue {issue_num} from client {client_id}")
|
720 |
|
721 |
# Broadcast the delta to all *other* connected clients
|
722 |
update_payload = json.dumps({
|
723 |
"type": "code_update",
|
724 |
"issue_num": issue_num,
|
725 |
+
"delta": delta # Send the original delta JSON string
|
726 |
})
|
727 |
|
728 |
tasks = []
|
|
|
737 |
# Log errors during broadcast
|
738 |
for i, result in enumerate(results):
|
739 |
if isinstance(result, Exception):
|
740 |
+
failed_client = tasks[i].__self__ # Get client from task
|
741 |
+
logger.warning(f"Failed to broadcast code update to client {getattr(failed_client, 'client_id', 'N/A')}: {result}")
|
742 |
|
743 |
except json.JSONDecodeError:
|
744 |
logger.error(f"Received invalid JSON delta for issue {issue_num}: {delta}")
|
|
|
757 |
return_exceptions=True
|
758 |
)
|
759 |
for i, result in enumerate(results):
|
760 |
+
client = self.ws_clients[i]
|
761 |
if isinstance(result, Exception):
|
762 |
+
logger.warning(f"Failed to send issue update notification to client {getattr(client, 'client_id', client.remote_address)}: {result}")
|
763 |
+
|
764 |
+
def remove_ws_client(self, client_to_remove: websockets.WebSocketServerProtocol):
|
765 |
+
"""Safely removes a client from the list and collaborator dict."""
|
766 |
+
client_id = getattr(client_to_remove, 'client_id', None)
|
767 |
+
if client_to_remove in self.ws_clients:
|
768 |
+
self.ws_clients.remove(client_to_remove)
|
769 |
+
logger.info(f"Removed WebSocket client: {client_id or client_to_remove.remote_address}")
|
770 |
+
if client_id and client_id in self.collaborators:
|
771 |
+
del self.collaborators[client_id]
|
772 |
+
logger.info(f"Removed collaborator {client_id}.")
|
773 |
+
# No need to broadcast here, the periodic task will reflect the change
|
774 |
|
775 |
|
776 |
# ========== Gradio UI Definition ==========
|
|
|
783 |
if issue_num is None or issue_num not in manager.issues:
|
784 |
return "<p>Select an issue from the board to see details.</p>"
|
785 |
issue = manager.issues[issue_num]
|
786 |
+
# Convert markdown body to HTML using markdown2 with extras
|
787 |
+
html_body = markdown2.markdown(
|
788 |
+
issue.get('body', '*No description provided.*'),
|
789 |
+
extras=["fenced-code-blocks", "tables", "strike", "task_list"]
|
790 |
+
)
|
791 |
# Basic styling
|
792 |
preview_html = f"""
|
793 |
+
<div style="border: 1px solid #e5e7eb; padding: 15px; border-radius: 8px; background-color: #f9fafb; font-family: 'Inter', sans-serif;">
|
794 |
+
<h4 style="margin-top: 0; margin-bottom: 10px;">
|
795 |
+
<a href='{issue.get('url', '#')}' target='_blank' style='color: #6d28d9; text-decoration: none; font-weight: 600;'>
|
796 |
+
#{issue['id']} - {issue.get('title', 'N/A')}
|
797 |
+
</a>
|
798 |
+
</h4>
|
799 |
<hr style='margin: 10px 0; border-top: 1px solid #e5e7eb;'>
|
800 |
+
<p style="font-size: 0.9em; color: #4b5563; margin-bottom: 5px;">
|
801 |
+
<strong>State:</strong> <span style="background-color: #ddd; padding: 1px 4px; border-radius: 3px;">{issue.get('state', 'N/A')}</span> |
|
802 |
+
<strong>Assignee:</strong> {issue.get('assignee', 'None')}
|
803 |
+
</p>
|
804 |
+
<p style="font-size: 0.9em; color: #4b5563; margin-bottom: 10px;">
|
805 |
+
<strong>Labels:</strong> {' | '.join(f'<span style=\'background-color: #eee; padding: 2px 5px; border-radius: 4px; font-size: 0.9em; display: inline-block; margin-right: 3px;\'>{l}</span>' for l in issue.get('labels', [])) or 'None'}
|
806 |
+
</p>
|
807 |
+
<div style="margin-top: 10px; max-height: 350px; overflow-y: auto; border-top: 1px dashed #ccc; padding-top: 10px; font-size: 0.95em; line-height: 1.5;">
|
808 |
{html_body}
|
809 |
</div>
|
810 |
</div>
|
811 |
"""
|
812 |
return preview_html
|
813 |
|
814 |
+
async def get_ai_suggestion_wrapper(issue_num: Optional[int], model_key: str) -> str:
|
815 |
+
"""Wrapper to get AI suggestion for the chat display."""
|
816 |
if issue_num is None or issue_num not in manager.issues:
|
817 |
return "Please select a valid issue first."
|
|
|
|
|
818 |
# Use cached_suggestion which handles the actual API call via lru_cache
|
819 |
+
# Note: cached_suggestion needs the issue *hash* and model *ID*
|
820 |
+
issue = manager.issues[issue_num]
|
821 |
+
issue_hash = manager._get_issue_hash(issue)
|
822 |
+
# Pass model_key directly, cached_suggestion will resolve it
|
823 |
+
suggestion = await manager.cached_suggestion(issue_hash, model_key)
|
824 |
+
# Format for display
|
825 |
+
return f"**Suggestion based on {model_key}:**\n\n---\n{suggestion}"
|
826 |
+
|
827 |
+
async def get_ai_patch_wrapper(issue_num: Optional[int], model_key: str) -> str:
|
828 |
+
"""Wrapper to get AI patch for the chat display."""
|
829 |
if issue_num is None or issue_num not in manager.issues:
|
830 |
return "Please select a valid issue first."
|
831 |
result = await manager.generate_code_patch(issue_num, model_key)
|
832 |
if "error" in result:
|
833 |
return f"**Error generating patch:** {result['error']}"
|
834 |
else:
|
835 |
+
# Format for chat display using Markdown code block
|
836 |
return f"""**Patch Suggestion from {result.get('model_used', model_key)}:**
|
837 |
|
838 |
**Explanation:**
|
839 |
{result.get('explanation', 'N/A')}
|
840 |
|
841 |
+
---
|
842 |
**Patch:**
|
843 |
```diff
|
844 |
{result.get('patch', 'N/A')}
|
|
|
860 |
with gr.Row():
|
861 |
github_token = gr.Textbox(label="GitHub Token (Optional)", type="password", info="Required for private repos or higher rate limits.", elem_id="github_token")
|
862 |
hf_token = gr.Textbox(label="Hugging Face Token", type="password", info="Required for AI model interactions.", elem_id="hf_token")
|
863 |
+
with gr.Column(scale=1, min_width=250):
|
|
|
864 |
model_select = gr.Dropdown(choices=list(HF_MODELS.keys()), value="Mistral-8x7B",
|
865 |
label="🤖 Select AI Model", info="Choose the AI for suggestions and patches.", elem_id="model_select")
|
866 |
crawl_btn = gr.Button("🛰️ Scan Repository Issues", variant="primary", icon="🔍", elem_id="crawl_btn")
|
867 |
+
status_output = gr.Textbox(label="Status", interactive=False, lines=1, max_lines=1, placeholder="Status updates will appear here...", elem_id="status_output")
|
868 |
|
869 |
|
870 |
# --- Main Tabs ---
|
871 |
with gr.Tabs(elem_id="main-tabs"):
|
872 |
# --- Issue Board Tab ---
|
873 |
with gr.Tab("📋 Issue Board", id="board", elem_id="tab-board"):
|
874 |
+
with gr.Row(equal_height=False):
|
875 |
with gr.Column(scale=3):
|
876 |
gr.Markdown("### Open Issues")
|
877 |
issue_list = gr.Dataframe(
|
|
|
880 |
interactive=True,
|
881 |
height=500,
|
882 |
wrap=True, # Wrap long titles
|
883 |
+
elem_id="issue_list_df",
|
884 |
+
overflow_row_behaviour='paginate', # Paginate if needed
|
885 |
+
max_rows=20 # Show 20 rows per page
|
886 |
)
|
887 |
+
with gr.Column(scale=2, min_width=350):
|
888 |
gr.Markdown("### Issue Severity")
|
889 |
stats_plot = gr.Plot(elem_id="stats_plot")
|
890 |
# Placeholder for collaborators - updated via JS
|
891 |
collab_status = gr.HTML("""
|
892 |
+
<div style="margin-top: 20px; border: 1px solid #e5e7eb; padding: 10px; border-radius: 8px; background-color: #f9fafb;">
|
893 |
+
<h4 style="margin-bottom: 5px; color: #374151; font-size: 1em;">👥 Active Collaborators</h4>
|
894 |
<div id="collab-list" style="font-size: 0.9em; max-height: 100px; overflow-y: auto;">
|
895 |
Connecting...
|
896 |
</div>
|
|
|
901 |
with gr.Tab("💻 Resolution Studio", id="studio", elem_id="tab-studio"):
|
902 |
with gr.Row():
|
903 |
# Left Column: Issue Details & AI Tools
|
904 |
+
with gr.Column(scale=1, min_width=400):
|
905 |
+
gr.Markdown("### Selected Issue Details")
|
906 |
# Hidden number input to store selected issue ID
|
907 |
selected_issue_id = gr.Number(label="Selected Issue ID", visible=False, precision=0, elem_id="selected_issue_id")
|
908 |
issue_preview_html = gr.HTML(
|
|
|
916 |
# Add placeholders for other buttons if needed
|
917 |
# test_btn = gr.Button("🧪 Create Tests (Future)", icon="🔬", interactive=False)
|
918 |
# impact_btn = gr.Button("📊 Impact Analysis (Future)", icon="📈", interactive=False)
|
919 |
+
# Use Markdown for better formatting of AI output
|
920 |
+
ai_output_display = gr.Markdown(value="*AI suggestions and patches will appear here...*", elem_id="ai_output_display")
|
921 |
|
922 |
|
923 |
+
# Right Column: Code Editor
|
924 |
+
with gr.Column(scale=2, min_width=500):
|
925 |
gr.Markdown("### Collaborative Code Editor")
|
926 |
# Use the imported code_editor component
|
927 |
# We'll update its value dynamically when an issue is selected
|
928 |
+
# Ensure the code_editor component itself handles language setting based on input dict keys or a prop
|
929 |
code_edit_component = code_editor(
|
930 |
label="Code Editor",
|
931 |
+
value={"placeholder.txt": "# Select an issue to load relevant code (placeholder)"},
|
932 |
+
# language="python", # Let component handle language if possible
|
933 |
+
elem_id="code_editor_component",
|
934 |
+
# Ensure the component has a reasonable height
|
935 |
+
# height=600 # This might need to be set via CSS or component props
|
936 |
)
|
937 |
# Hidden input to trigger code editor updates from server->client WS messages
|
938 |
+
# This is a fallback if direct JS manipulation of the editor isn't feasible
|
939 |
code_editor_update_trigger = gr.Textbox(visible=False, elem_id="code-editor-update-trigger")
|
940 |
|
941 |
|
|
|
958 |
fn=manager.crawl_issues,
|
959 |
inputs=[repo_url, github_token, hf_token],
|
960 |
outputs=[issue_list, stats_plot, status_output],
|
961 |
+
api_name="crawl_issues", # For API access if needed
|
962 |
+
show_progress="full"
|
963 |
)
|
964 |
|
965 |
# 2. Issue Selection in Dataframe
|
966 |
async def handle_issue_select(evt: gr.SelectData):
|
967 |
"""Handles issue selection: updates preview, loads code (placeholder)."""
|
968 |
+
if evt.index[0] is None or evt.value is None: # No row selected or value missing
|
969 |
+
logger.info("Issue deselected or invalid selection event.")
|
970 |
return {
|
971 |
+
selected_issue_id: gr.update(value=None),
|
972 |
issue_preview_html: "<p style='color: #6b7280;'>Select an issue from the table.</p>",
|
973 |
+
code_edit_component: gr.update(value={"placeholder.txt": "# Select an issue to load code."}),
|
974 |
+
ai_output_display: "*AI suggestions and patches will appear here...*" # Reset AI output
|
975 |
}
|
976 |
|
977 |
+
try:
|
978 |
+
selected_id = int(evt.value[0]) # Get ID from the first column ('ID') of the selected row value
|
979 |
+
logger.info(f"Issue selected: ID {selected_id}")
|
980 |
+
|
981 |
+
# Update the hidden ID field
|
982 |
+
updates = {selected_issue_id: selected_id}
|
983 |
+
|
984 |
+
# Generate and update the HTML preview
|
985 |
+
preview_html = generate_issue_preview(selected_id)
|
986 |
+
updates[issue_preview_html] = preview_html
|
987 |
+
|
988 |
+
# --- Code Loading Logic (Placeholder) ---
|
989 |
+
# This needs real implementation: Find relevant files for the issue
|
990 |
+
# and load their content into the editor component's value format.
|
991 |
+
# Example: Fetch files related to the issue (needs implementation)
|
992 |
+
# files_content = await fetch_relevant_code_for_issue(selected_id)
|
993 |
+
# For now, create a placeholder editor state and potentially initialize server-side OT editor
|
994 |
+
files_content = {
|
995 |
+
f"issue_{selected_id}_code.py": f"# Code related to issue {selected_id}\n# (Replace with actual file content)\n\nprint('Hello from issue {selected_id}')",
|
996 |
+
"README.md": f"# Issue {selected_id}\n\nDetails about the issue..."
|
997 |
+
}
|
998 |
+
# Initialize or update the server-side OT document for this issue
|
999 |
+
# This assumes OTCodeEditor takes initial content dictionary
|
1000 |
+
manager.code_editors[selected_id] = OTCodeEditor(value=files_content)
|
1001 |
+
logger.info(f"Initialized/Reset OT editor state for issue {selected_id}")
|
1002 |
|
1003 |
+
updates[code_edit_component] = gr.update(value=files_content)
|
1004 |
+
# --- End Placeholder ---
|
1005 |
|
1006 |
+
# Reset AI output display
|
1007 |
+
updates[ai_output_display] = "*AI suggestions and patches will appear here...*"
|
|
|
1008 |
|
1009 |
+
return updates
|
1010 |
+
except (ValueError, TypeError, IndexError) as e:
|
1011 |
+
logger.error(f"Error processing selection event data: {evt.value}. Error: {e}")
|
1012 |
+
return { # Return updates to reset state gracefully
|
1013 |
+
selected_issue_id: gr.update(value=None),
|
1014 |
+
issue_preview_html: "<p style='color: red;'>Error processing selection. Please try again.</p>",
|
1015 |
+
code_edit_component: gr.update(value={"error.txt": "# Error processing selection"}),
|
1016 |
+
ai_output_display: "*Error processing selection.*"
|
1017 |
+
}
|
|
|
|
|
1018 |
|
|
|
1019 |
|
1020 |
issue_list.select(
|
1021 |
fn=handle_issue_select,
|
1022 |
inputs=[], # Event data is passed automatically
|
1023 |
+
outputs=[selected_issue_id, issue_preview_html, code_edit_component, ai_output_display],
|
1024 |
show_progress="minimal"
|
1025 |
)
|
1026 |
|
1027 |
# 3. Suggest Resolution Button Click
|
1028 |
suggest_btn.click(
|
1029 |
+
fn=get_ai_suggestion_wrapper,
|
1030 |
inputs=[selected_issue_id, model_select],
|
1031 |
+
outputs=[ai_output_display], # Output to Markdown component
|
1032 |
+
api_name="suggest_resolution",
|
1033 |
+
show_progress="full"
|
1034 |
)
|
1035 |
|
1036 |
# 4. Generate Patch Button Click
|
1037 |
patch_btn.click(
|
1038 |
+
fn=get_ai_patch_wrapper,
|
1039 |
inputs=[selected_issue_id, model_select],
|
1040 |
+
outputs=[ai_output_display], # Output to Markdown component
|
1041 |
+
api_name="generate_patch",
|
1042 |
+
show_progress="full"
|
1043 |
)
|
1044 |
|
1045 |
# 5. Code Editor Change (User typing) -> Send update via WebSocket
|
|
|
1057 |
client_id = f"client_{hashlib.sha1(os.urandom(16)).hexdigest()[:8]}"
|
1058 |
logger.info(f"Generated Client ID for WebSocket: {client_id}")
|
1059 |
|
1060 |
+
# FIX: Escape all literal curly braces `{` -> `{{` and `}` -> `}}` in the JS code
|
1061 |
+
# Variables like {ws_port} and {client_id} remain single-braced.
|
1062 |
return f"""
|
1063 |
<script>
|
1064 |
+
// Ensure this runs only once per page load
|
1065 |
if (!window.collabWs) {{
|
1066 |
console.log('Initializing WebSocket connection...');
|
1067 |
+
// Determine WebSocket URL based on environment (local vs HF Space)
|
1068 |
+
let wsUrl;
|
1069 |
+
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {{
|
1070 |
+
wsUrl = `ws://localhost:{ws_port}`;
|
1071 |
+
}} else {{
|
1072 |
+
// Assume running on HF Space or similar, use wss://
|
1073 |
+
// Construct the WS URL based on the current window location (more robust)
|
1074 |
+
const spaceHost = window.location.host;
|
1075 |
+
wsUrl = `wss://${{spaceHost}}/ws`; // Standard path for Gradio WS proxy
|
1076 |
+
console.log('Detected non-local environment, using secure WebSocket URL:', wsUrl);
|
1077 |
+
}}
|
1078 |
+
|
1079 |
|
1080 |
window.collabWs = new WebSocket(wsUrl);
|
1081 |
window.clientId = '{client_id}'; // Store client ID globally for this session
|
1082 |
+
window.aceEditorInstance = null; // Store reference to the Ace editor
|
1083 |
+
window.currentIssueId = null; // Track the currently selected issue ID in the editor
|
1084 |
|
1085 |
window.collabWs.onopen = function(event) {{
|
1086 |
console.log('WebSocket connection established.');
|
1087 |
+
// Send join message with client ID
|
1088 |
+
window.sendWsMessage({{ type: 'join', clientId: window.clientId }});
|
1089 |
+
// Update collaborator list display
|
1090 |
const collabListDiv = document.getElementById('collab-list');
|
1091 |
+
if (collabListDiv) collabListDiv.innerHTML = 'Connected. Waiting for status...';
|
1092 |
}};
|
1093 |
|
1094 |
window.collabWs.onmessage = function(event) {{
|
|
|
1096 |
try {{
|
1097 |
const data = JSON.parse(event.data);
|
1098 |
|
1099 |
+
// --- Collaboration Status Update ---
|
1100 |
if (data.type === 'collaboration_status') {{
|
1101 |
const collabListDiv = document.getElementById('collab-list');
|
1102 |
if (collabListDiv) {{
|
1103 |
+
const collaborators = data.collaborators || {{}};
|
1104 |
+
const activeCollaborators = Object.entries(collaborators)
|
1105 |
+
.filter(([id, info]) => id !== window.clientId); // Exclude self
|
1106 |
+
|
1107 |
+
if (activeCollaborators.length > 0) {{
|
1108 |
+
collabListDiv.innerHTML = activeCollaborators
|
1109 |
+
.map(([id, info]) => `<div class="collab-item" style="margin-bottom: 3px;">${{info.name || id}}: ${{info.status || 'Idle'}}</div>`)
|
1110 |
.join('');
|
1111 |
}} else {{
|
1112 |
+
collabListDiv.innerHTML = 'You are the only active user.';
|
1113 |
}}
|
1114 |
}}
|
1115 |
+
// --- Code Update from another client ---
|
1116 |
}} else if (data.type === 'code_update') {{
|
1117 |
+
console.log('Received code update delta for issue:', data.issue_num, 'from client:', data.clientId);
|
1118 |
+
// Apply delta only if it's for the currently viewed issue and not from self
|
1119 |
+
if (window.aceEditorInstance && data.issue_num === window.currentIssueId && data.clientId !== window.clientId) {{
|
1120 |
+
try {{
|
1121 |
+
const delta = JSON.parse(data.delta); // Parse the delta string
|
1122 |
+
// Prevent triggering the 'change' listener we set up
|
1123 |
+
window.aceEditorInstance.getSession().getDocument().applyDeltas([delta]);
|
1124 |
+
console.log('Applied remote delta to Ace editor for issue:', data.issue_num);
|
1125 |
+
}} catch (e) {{
|
1126 |
+
console.error('Failed to parse or apply remote delta:', e, data.delta);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1127 |
}}
|
|
|
1128 |
}} else {{
|
1129 |
+
console.log('Ignoring remote delta (wrong issue, self, or editor not ready). Current:', window.currentIssueId, 'Received:', data.issue_num);
|
1130 |
}}
|
1131 |
+
// --- Issue List Update Notification ---
|
1132 |
}} else if (data.type === 'issues_updated') {{
|
1133 |
console.log('Received issues updated notification.');
|
1134 |
+
// Update status bar to prompt user action
|
1135 |
+
const statusBar = document.getElementById('status_output')?.querySelector('textarea');
|
|
|
1136 |
if (statusBar) {{
|
1137 |
+
const timestamp = new Date().toLocaleTimeString();
|
1138 |
+
statusBar.value = `[${{timestamp}}] Issue list updated on server. Click "Scan Repository Issues" to refresh.`;
|
1139 |
+
// Manually dispatch input event for Gradio
|
1140 |
statusBar.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
1141 |
}}
|
1142 |
+
// Optionally add a more visible notification element
|
1143 |
}}
|
1144 |
|
1145 |
}} catch (e) {{
|
1146 |
+
console.error('Failed to parse WebSocket message or update UI:', e, event.data);
|
1147 |
}}
|
1148 |
}};
|
1149 |
|
|
|
1151 |
console.warn('WebSocket connection closed:', event.code, event.reason);
|
1152 |
const collabListDiv = document.getElementById('collab-list');
|
1153 |
if (collabListDiv) collabListDiv.innerHTML = '<span style="color: red;">Disconnected</span>';
|
1154 |
+
// Implement basic reconnection logic (optional)
|
1155 |
+
// setTimeout(initWebSocket, 5000); // Attempt to reconnect after 5 seconds
|
1156 |
}};
|
1157 |
|
1158 |
window.collabWs.onerror = function(error) {{
|
|
|
1161 |
if (collabListDiv) collabListDiv.innerHTML = '<span style="color: red;">Connection Error</span>';
|
1162 |
}};
|
1163 |
|
1164 |
+
// Function to send messages
|
1165 |
window.sendWsMessage = function(message) {{
|
1166 |
if (window.collabWs && window.collabWs.readyState === WebSocket.OPEN) {{
|
1167 |
window.collabWs.send(JSON.stringify(message));
|
1168 |
}} else {{
|
1169 |
+
console.error('WebSocket not connected. Cannot send message:', message);
|
1170 |
}}
|
1171 |
}};
|
1172 |
|
1173 |
// --- JS Integration with Code Editor Component ---
|
1174 |
+
// Tries to find the Ace editor instance and attach listeners.
|
1175 |
+
// Needs to run after the component is rendered.
|
|
|
|
|
|
|
|
|
|
|
|
|
1176 |
function setupCodeEditorListener() {{
|
1177 |
+
// Check if Ace is loaded
|
1178 |
+
if (typeof ace === 'undefined') {{
|
1179 |
+
console.warn('Ace editor library not found. Retrying...');
|
1180 |
+
setTimeout(setupCodeEditorListener, 1000);
|
1181 |
+
return;
|
1182 |
+
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1183 |
|
1184 |
+
const editorElement = document.querySelector('#code_editor_component .ace_editor');
|
1185 |
+
if (!editorElement) {{
|
1186 |
+
console.warn('Ace editor element not found yet. Retrying...');
|
1187 |
+
setTimeout(setupCodeEditorListener, 1000); // Retry if element not ready
|
1188 |
+
return;
|
1189 |
+
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1190 |
|
1191 |
+
// Get Ace editor instance (may vary based on component implementation)
|
1192 |
+
try {{
|
1193 |
+
window.aceEditorInstance = ace.edit(editorElement);
|
1194 |
+
console.log('Ace Editor instance found:', window.aceEditorInstance);
|
1195 |
+
}} catch (e) {{
|
1196 |
+
console.error('Failed to initialize Ace editor instance:', e);
|
1197 |
+
// Maybe the instance is already created and stored differently?
|
1198 |
+
// Look for common patterns if direct init fails.
|
1199 |
+
if (editorElement.env && editorElement.env.editor) {{
|
1200 |
+
window.aceEditorInstance = editorElement.env.editor;
|
1201 |
+
console.log('Found Ace Editor instance via element.env.editor');
|
1202 |
+
}} else {{
|
1203 |
+
console.error('Could not find Ace editor instance. Collaboration may not work.');
|
1204 |
+
return; // Stop if editor cannot be found
|
1205 |
+
}}
|
1206 |
+
}}
|
1207 |
|
1208 |
+
|
1209 |
+
if (window.aceEditorInstance) {{
|
1210 |
+
console.log('Attaching change listener to Ace editor.');
|
1211 |
+
window.aceEditorInstance.getSession().on('change', function(delta) {{
|
1212 |
+
// Check if the change was initiated by the current user
|
1213 |
+
// `aceEditor.curOp` is one way, but might not always be reliable.
|
1214 |
+
// A simpler check: ignore deltas if they originate from applyDeltas (often used for remote changes)
|
1215 |
+
const isUserChange = !delta.ignore; // Add an 'ignore' flag when applying remote deltas
|
1216 |
+
|
1217 |
+
// More robust check: Use internal flags if available
|
1218 |
+
const isUserOriginated = window.aceEditorInstance.curOp && window.aceEditorInstance.curOp.command.name;
|
1219 |
+
|
1220 |
+
if (isUserOriginated) {{
|
1221 |
+
// console.log('Code changed by user:', delta);
|
1222 |
+
// Get the current issue ID from the hidden Gradio input
|
1223 |
+
const issueIdInput = document.querySelector('#selected_issue_id input');
|
1224 |
+
const currentIssueIdStr = issueIdInput ? issueIdInput.value : null;
|
1225 |
+
window.currentIssueId = currentIssueIdStr ? parseInt(currentIssueIdStr, 10) : null;
|
1226 |
+
|
1227 |
+
if (window.currentIssueId !== null && !isNaN(window.currentIssueId)) {{
|
1228 |
+
// Send the delta via WebSocket
|
1229 |
+
window.sendWsMessage({{
|
1230 |
+
type: 'code_update',
|
1231 |
+
issue_num: window.currentIssueId,
|
1232 |
+
delta: JSON.stringify(delta), // Send delta as JSON string
|
1233 |
+
clientId: window.clientId
|
1234 |
+
}});
|
1235 |
+
}} else {{
|
1236 |
+
// console.warn('No valid issue selected, cannot send code update.');
|
1237 |
+
}}
|
1238 |
+
}} else {{
|
1239 |
+
// console.log('Ignoring programmatic change delta:', delta);
|
1240 |
+
}}
|
1241 |
+
}});
|
1242 |
+
console.log('Ace editor listener attached.');
|
1243 |
+
}} else {{
|
1244 |
+
console.error('Ace editor instance is null after setup attempt.');
|
1245 |
+
}}
|
1246 |
+
}} // end setupCodeEditorListener
|
1247 |
+
|
1248 |
+
// --- Initialization and Observation ---
|
1249 |
+
// Use MutationObserver to detect when the editor component is added/changed in the DOM.
|
1250 |
+
const observerTarget = document.body; // Observe the whole body or a closer parent container
|
1251 |
+
const observer = new MutationObserver((mutationsList, observer) => {{
|
1252 |
for(const mutation of mutationsList) {{
|
1253 |
+
if (mutation.type === 'childList') {{
|
1254 |
+
// Check if the specific editor element we need is now present
|
1255 |
if (document.querySelector('#code_editor_component .ace_editor')) {{
|
1256 |
+
console.log("Ace editor element detected in DOM, attempting setup...");
|
1257 |
+
// Debounce setup call slightly to ensure editor is fully ready
|
1258 |
+
clearTimeout(window.setupEditorTimeout);
|
1259 |
+
window.setupEditorTimeout = setTimeout(setupCodeEditorListener, 200);
|
1260 |
+
// Optionally disconnect observer if setup only needs to happen once,
|
1261 |
+
// but re-observing might be needed if component re-renders completely.
|
1262 |
+
// observer.disconnect();
|
1263 |
+
// break; // Found it, no need to check other mutations in this batch
|
1264 |
}}
|
1265 |
}}
|
1266 |
}}
|
1267 |
}});
|
1268 |
+
|
1269 |
+
// Start observing the target node for configured mutations
|
1270 |
+
if(observerTarget) {{
|
1271 |
+
console.log("Starting MutationObserver to detect code editor element.");
|
1272 |
+
observer.observe(observerTarget, {{ childList: true, subtree: true }});
|
1273 |
+
}}
|
1274 |
+
|
1275 |
+
// Initial attempt to set up listener after a short delay, in case element exists on load
|
1276 |
+
setTimeout(setupCodeEditorListener, 500);
|
1277 |
|
1278 |
|
1279 |
}} else {{
|
1280 |
+
console.log('WebSocket connection appears to be already initialized.');
|
1281 |
}}
|
1282 |
</script>
|
1283 |
"""
|
1284 |
|
1285 |
+
# Inject the JavaScript into the Gradio app when it loads
|
1286 |
app.load(_js=web_socket_js(WS_PORT), fn=None, inputs=None, outputs=None)
|
1287 |
|
1288 |
return app
|
|
|
1293 |
"""Handles incoming WebSocket connections and messages."""
|
1294 |
client_id = None # Initialize client_id for this connection
|
1295 |
manager.ws_clients.append(websocket)
|
1296 |
+
logger.info(f"WebSocket client connected: {websocket.remote_address} (Total: {len(manager.ws_clients)})")
|
1297 |
try:
|
1298 |
async for message in websocket:
|
1299 |
try:
|
1300 |
data = json.loads(message)
|
1301 |
msg_type = data.get("type")
|
1302 |
+
# logger.debug(f"Received WS message: {data}") # Log received message content
|
1303 |
|
1304 |
if msg_type == "join":
|
1305 |
client_id = data.get("clientId", f"anon_{websocket.id}")
|
1306 |
setattr(websocket, 'client_id', client_id) # Associate ID with socket object
|
1307 |
manager.collaborators[client_id] = {"name": client_id, "status": "Connected"} # Add to collaborators
|
1308 |
logger.info(f"Client {client_id} joined.")
|
1309 |
+
# Trigger immediate broadcast in case it's needed, but don't await
|
1310 |
+
asyncio.create_task(manager.broadcast_collaboration_status())
|
1311 |
+
|
1312 |
|
1313 |
elif msg_type == "code_update":
|
1314 |
issue_num = data.get("issue_num")
|
|
|
1326 |
if sender_id and sender_id in manager.collaborators:
|
1327 |
manager.collaborators[sender_id]["status"] = status
|
1328 |
logger.info(f"Client {sender_id} status updated: {status}")
|
1329 |
+
# Trigger broadcast, don't await
|
1330 |
+
asyncio.create_task(manager.broadcast_collaboration_status())
|
1331 |
+
|
1332 |
|
1333 |
else:
|
1334 |
+
logger.warning(f"Unknown WebSocket message type received: {msg_type} from {client_id or websocket.remote_address}")
|
1335 |
|
1336 |
except json.JSONDecodeError:
|
1337 |
+
logger.error(f"Received invalid JSON over WebSocket from {client_id or websocket.remote_address}: {message}")
|
1338 |
except Exception as e:
|
1339 |
+
logger.exception(f"Error processing WebSocket message from {client_id or websocket.remote_address}: {e}")
|
1340 |
|
1341 |
except ConnectionClosed as e:
|
1342 |
+
logger.info(f"WebSocket client {client_id or websocket.remote_address} disconnected: (Code: {e.code}, Reason: {e.reason})")
|
1343 |
except Exception as e:
|
1344 |
+
logger.exception(f"Unexpected error in WebSocket handler for {client_id or websocket.remote_address}: {e}")
|
1345 |
finally:
|
1346 |
+
logger.info(f"Cleaning up connection for client {client_id or websocket.remote_address}")
|
1347 |
+
# Use the safe removal method, ensuring it runs in the correct context if needed
|
1348 |
+
manager.remove_ws_client(websocket)
|
1349 |
+
# Trigger a final status broadcast
|
1350 |
+
asyncio.create_task(manager.broadcast_collaboration_status())
|
1351 |
+
|
1352 |
|
1353 |
async def start_websocket_server(manager: IssueManager, port: int):
|
1354 |
"""Starts the WebSocket server."""
|
1355 |
# Pass manager instance to the connection handler factory
|
1356 |
+
handler_with_manager = lambda ws, path: handle_ws_connection(ws, path, manager)
|
1357 |
+
try:
|
1358 |
+
# Set ping interval and timeout to keep connections alive and detect broken ones
|
1359 |
+
server = await websockets.serve(
|
1360 |
+
handler_with_manager,
|
1361 |
+
"0.0.0.0", # Bind to all interfaces
|
1362 |
+
port,
|
1363 |
+
ping_interval=20, # Send pings every 20 seconds
|
1364 |
+
ping_timeout=20 # Wait 20 seconds for pong response
|
1365 |
+
)
|
1366 |
+
logger.info(f"WebSocket server started on ws://0.0.0.0:{port}")
|
1367 |
+
await asyncio.Future() # Run forever until cancelled
|
1368 |
+
except OSError as e:
|
1369 |
+
logger.error(f"Failed to start WebSocket server on port {port}: {e}. Port might be in use.")
|
1370 |
+
# Exit or handle error appropriately
|
1371 |
+
raise
|
1372 |
+
except Exception as e:
|
1373 |
+
logger.exception(f"Unexpected error starting WebSocket server: {e}")
|
1374 |
+
raise
|
1375 |
+
|
1376 |
|
1377 |
def run_webhook_server(manager: IssueManager, port: int):
|
1378 |
"""Starts the HTTP webhook server in a separate thread."""
|
1379 |
WebhookHandler.manager_instance = manager # Pass manager instance to the class
|
1380 |
+
server_address = ("0.0.0.0", port) # Bind to all interfaces
|
1381 |
+
try:
|
1382 |
+
httpd = HTTPServer(server_address, WebhookHandler)
|
1383 |
+
logger.info(f"Webhook HTTP server started on [http://0.0.0.0](http://0.0.0.0):{port}")
|
1384 |
+
httpd.serve_forever()
|
1385 |
+
except OSError as e:
|
1386 |
+
logger.error(f"Failed to start Webhook server on port {port}: {e}. Port might be in use.")
|
1387 |
+
# Exit or handle error appropriately
|
1388 |
+
# Consider signaling the main thread to stop
|
1389 |
+
except Exception as e:
|
1390 |
+
logger.exception(f"Unexpected error in Webhook server: {e}")
|
1391 |
|
1392 |
|
1393 |
# ========== Main Execution ==========
|
1394 |
if __name__ == "__main__":
|
1395 |
# --- Setup ---
|
1396 |
manager = IssueManager()
|
1397 |
+
main_event_loop = asyncio.get_event_loop()
|
1398 |
+
manager.main_loop = main_event_loop # Ensure manager has loop reference if needed
|
1399 |
|
1400 |
# --- Start Background Servers ---
|
1401 |
+
# 1. Webhook Server (HTTP) - Runs in its own thread
|
1402 |
webhook_thread = threading.Thread(target=run_webhook_server, args=(manager, WEBHOOK_PORT), daemon=True)
|
1403 |
webhook_thread.start()
|
1404 |
|
1405 |
+
# 2. WebSocket Server & Broadcast Task (Asyncio) - Run in main thread's event loop
|
|
|
|
|
1406 |
async def main_async_tasks():
|
1407 |
# Start the periodic broadcast task
|
1408 |
broadcast_task = asyncio.create_task(manager.broadcast_collaboration_status())
|
1409 |
# Start the WebSocket server
|
1410 |
websocket_server_task = asyncio.create_task(start_websocket_server(manager, WS_PORT))
|
1411 |
+
# Keep tasks running
|
1412 |
+
await asyncio.gather(broadcast_task, websocket_server_task, return_exceptions=True)
|
1413 |
|
1414 |
+
# --- Create Gradio App ---
|
1415 |
+
# Must be created before starting the loop if it interacts with async functions directly
|
|
|
|
|
|
|
1416 |
app = create_ui(manager)
|
1417 |
+
|
1418 |
+
# --- Launch Gradio App & Async Tasks ---
|
1419 |
+
# Gradio's launch method handles the event loop integration when run directly.
|
1420 |
+
# It will run the asyncio tasks alongside the FastAPI/Uvicorn server.
|
1421 |
+
app.queue() # Enable queue for handling multiple requests/long-running tasks
|
1422 |
app.launch(
|
1423 |
# share=True, # Enable for public access (use with caution)
|
1424 |
+
server_name="0.0.0.0", # Bind to all interfaces for accessibility
|
1425 |
server_port=7860, # Default Gradio port
|
1426 |
+
favicon_path="[https://huggingface.co/front/assets/huggingface_logo-noborder.svg](https://huggingface.co/front/assets/huggingface_logo-noborder.svg)",
|
1427 |
+
# Let Gradio manage the asyncio loop
|
1428 |
+
# asyncio_task=main_async_tasks() # This might conflict with launch's loop management
|
1429 |
)
|
1430 |
|
1431 |
+
# If launch() blocks, the asyncio tasks need to be run differently,
|
1432 |
+
# potentially starting the loop manually before launch or using threading
|
1433 |
+
# for the asyncio part if launch() isn't compatible.
|
1434 |
+
# However, modern Gradio often handles this integration better.
|
1435 |
+
|
1436 |
+
# Start the asyncio tasks *after* defining the app but *before* launch blocks,
|
1437 |
+
# or ensure launch itself runs them. Gradio's `launch` usually handles the loop.
|
1438 |
+
# Let's rely on Gradio's launch to manage the loop and potentially run our tasks.
|
1439 |
+
# If WS/broadcast doesn't work, we may need to manually manage the loop/threading.
|
1440 |
+
|
1441 |
+
# A common pattern if launch() blocks and doesn't run background async tasks:
|
1442 |
+
# asyncio_thread = threading.Thread(target=lambda: asyncio.run(main_async_tasks()), daemon=True)
|
1443 |
+
# asyncio_thread.start()
|
1444 |
+
# app.launch(...)
|
1445 |
+
|
1446 |
+
logger.info("Gradio app launched. Webhook server running in background thread.")
|
1447 |
+
# The asyncio tasks (WebSocket, broadcast) should be running via Gradio's event loop.
|
1448 |
+
|
1449 |
|