acecalisto3 commited on
Commit
e5ab8a2
·
verified ·
1 Parent(s): 3e6657e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +588 -311
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 = asyncio.get_event_loop()
100
- asyncio.run_coroutine_threadsafe(
101
- WebhookHandler.manager_instance.handle_webhook_event(event, action, payload),
102
- loop
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.WebSocketClientProtocol] = []
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, model: str) -> 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[issue_number] = issue_data # Update issue data
 
 
 
 
 
 
186
  else:
187
  logger.info(f"Ignoring action '{action}' for issue {issue_number}.")
188
 
189
- # Consider triggering a UI update after handling the webhook
190
- # This might involve re-crawling or just updating the specific issue
191
- await self.broadcast_issue_update() # Example function to notify clients
 
 
 
 
 
 
 
 
 
 
 
 
 
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 github_token or not hf_token:
198
- return [], go.Figure(), "Error: Repository URL, GitHub Token, and HF Token are required."
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
- async with session.get(api_url) as response:
222
- response.raise_for_status() # Raise exception for bad status codes
223
- issues_data = await response.json()
224
- logger.info(f"Fetched {len(issues_data)} open issues.")
225
- for issue in issues_data:
226
- issue_number = issue['number']
227
- self.issues[issue_number] = {
228
- "id": issue_number,
229
- "title": issue.get('title', 'No Title'),
230
- "body": issue.get('body', ''),
231
- "state": issue.get('state', 'unknown'),
232
- "labels": [label['name'] for label in issue.get('labels', [])],
233
- "assignee": issue.get('assignee', {}).get('login') if issue.get('assignee') else None,
234
- "url": issue.get('html_url', '#')
235
- # Add other relevant fields if needed
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 cloning repository: {e}"
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 = list(severity_counts.keys())
301
- counts = list(severity_counts.values())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- showlegend=False, # Hide legend if coloring by severity directly
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
- embeddings = await self._generate_embeddings([f"{i.get('title','')} {i.get('body','')}" for i in self.issue_list_for_clustering])
336
- if not embeddings or len(embeddings) != len(self.issue_list_for_clustering):
337
- logger.error("Failed to generate valid embeddings for all issues.")
 
 
 
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 (excluding noise).")
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
- async with aiohttp.ClientSession(headers=headers) as session:
 
 
376
  try:
377
- response = await session.post(api_url, json={"inputs": texts})
 
 
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
- logger.error(f"Response body: {await e.response.text()}")
 
 
 
 
 
 
 
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 (Simplified) ---
409
- # In a real scenario, this needs to be much smarter:
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 tasked with fixing a bug described in a GitHub issue.
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 Title: {issue.get('title', 'N/A')}
423
- ## Issue Body:
 
424
  {issue.get('body', 'N/A')}
 
425
 
426
  ## Relevant Code Context (if available):
427
  {context}
428
 
429
  ## Instructions:
430
- 1. Analyze the issue and the context.
431
- 2. Determine the code changes needed.
432
- 3. Provide the changes as a Git diff block (```diff ... ```).
433
- 4. If you cannot determine a patch, explain why.
 
 
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": 1024, # Max length of the generated patch + explanation
445
- "temperature": 0.3, # Lower temperature for more deterministic code
446
  "return_full_text": False, # Only get the generated part
447
- "do_sample": True,
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
- # Basic extraction of diff block (improve if needed)
461
  diff_match = re.search(r"```diff\n(.*?)```", generated_text, re.DOTALL)
462
  explanation = generated_text.split("```diff")[0].strip()
463
- patch = diff_match.group(1).strip() if diff_match else "No diff block found in response."
 
 
 
 
 
 
 
 
 
 
 
 
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
- logger.error(f"Response body: {await e.response.text()}")
 
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 Title: {issue.get('title', 'N/A')}
510
- ## Issue Body:
 
511
  {issue.get('body', 'N/A')}
512
- ## Labels: {', '.join(issue.get('labels', []))}
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": 512,
522
- "temperature": 0.7, # Higher temp for more creative suggestions
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
- logger.error(f"Response body: {await e.response.text()}")
 
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 {i}: {result}")
 
 
 
 
 
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
- logger.warning(f"Received code update for non-existent editor for issue {issue_num}")
577
- return # Or initialize editor: self.code_editors[issue_num] = OTCodeEditor(...)
 
 
 
 
578
 
579
  try:
580
  # Apply the delta to the server-side authoritative state
581
- self.code_editors[issue_num].apply_delta(json.loads(delta))
 
 
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
- logger.warning(f"Failed to broadcast code update to client {i}: {result}")
 
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 {i}: {result}")
 
 
 
 
 
 
 
 
 
 
 
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(issue.get('body', '*No description provided.*'))
 
 
 
638
  # Basic styling
639
  preview_html = f"""
640
- <div style="border: 1px solid #e5e7eb; padding: 15px; border-radius: 8px; background-color: #f9fafb;">
641
- <h4><a href='{issue.get('url', '#')}' target='_blank' style='color: #6d28d9; text-decoration: none;'>#{issue['id']} - {issue.get('title', 'N/A')}</a></h4>
 
 
 
 
642
  <hr style='margin: 10px 0; border-top: 1px solid #e5e7eb;'>
643
- <p><strong>State:</strong> {issue.get('state', 'N/A')} | <strong>Assignee:</strong> {issue.get('assignee', 'None')}</p>
644
- <p><strong>Labels:</strong> {' | '.join(f'<span style=\'background-color: #eee; padding: 2px 5px; border-radius: 4px; font-size: 0.9em;\'>{l}</span>' for l in issue.get('labels', [])) or 'None'}</p>
645
- <div style="margin-top: 10px; max-height: 300px; overflow-y: auto; border-top: 1px dashed #ccc; padding-top: 10px;">
 
 
 
 
 
646
  {html_body}
647
  </div>
648
  </div>
649
  """
650
  return preview_html
651
 
652
- async def get_ai_suggestion(issue_num: Optional[int], model_key: str) -> str:
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
- suggestion = await manager.cached_suggestion(issue_hash, HF_MODELS[model_key])
660
- # Format for chat
661
- return f"**Suggestion based on {model_key}:**\n\n{suggestion}"
662
-
663
- async def get_ai_patch(issue_num: Optional[int], model_key: str) -> str:
664
- """Wrapper to get AI patch for the chat."""
 
 
 
 
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
- chat_output_display = gr.Textbox(label="AI Output", lines=10, interactive=False, placeholder="AI suggestions and patches will appear here...", elem_id="ai_output_display")
 
754
 
755
 
756
- # Right Column: Code Editor & Chat (removed chat interface)
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
- # Initial value can be empty or a placeholder message
764
- value={"main.py": "# Select an issue to load relevant code (placeholder)"},
765
- # Language is set dynamically if needed, or defaults
766
- language="python", # Default language
767
- elem_id="code_editor_component"
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
- # Reset code editor or show placeholder
803
- code_edit_component: gr.update(value={"placeholder.txt": "# Select an issue to load code."})
804
  }
805
 
806
- selected_id = int(evt.value[0]) # Get ID from the first column ('ID') of the selected row
807
- logger.info(f"Issue selected: ID {selected_id}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
808
 
809
- # Update the hidden ID field
810
- updates = {selected_issue_id: selected_id}
811
 
812
- # Generate and update the HTML preview
813
- preview_html = generate_issue_preview(selected_id)
814
- updates[issue_preview_html] = preview_html
815
 
816
- # --- Code Loading Logic (Placeholder) ---
817
- # This needs real implementation: Find relevant files for the issue
818
- # and load their content into the editor component's value format.
819
- # Example: Fetch files related to the issue (needs implementation)
820
- # files_content = await fetch_relevant_code_for_issue(selected_id)
821
- files_content = {
822
- 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}')",
823
- "README.md": f"# Issue {selected_id}\n\nDetails about the issue..."
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=get_ai_suggestion,
840
  inputs=[selected_issue_id, model_select],
841
- outputs=[chat_output_display],
842
- api_name="suggest_resolution"
 
843
  )
844
 
845
  # 4. Generate Patch Button Click
846
  patch_btn.click(
847
- fn=get_ai_patch,
848
  inputs=[selected_issue_id, model_select],
849
- outputs=[chat_output_display],
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
- const wsUrl = `ws://localhost:{ws_port}`; // Use localhost for local Gradio run
874
- // For Hugging Face Spaces, you need to use the public WS endpoint:
875
- // const wsUrl = `wss://YOUR_SPACE_NAME.hf.space/ws`; // Adjust if using custom domain/port mapping
 
 
 
 
 
 
 
 
 
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
- // Optionally send a join message
883
- window.collabWs.send(JSON.stringify({{ type: 'join', clientId: window.clientId }}));
884
- // Initial update for collaborator list (optional)
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
- if (Object.keys(data.collaborators).length > 0) {{
898
- collabListDiv.innerHTML = Object.entries(data.collaborators)
899
- .map(([id, info]) => `<div class="collab-item">${info.name || id}: ${info.status || 'Idle'}</div>`)
 
 
 
 
900
  .join('');
901
  }} else {{
902
- collabListDiv.innerHTML = 'No other collaborators active.';
903
  }}
904
  }}
 
905
  }} else if (data.type === 'code_update') {{
906
- console.log('Received code update delta for issue:', data.issue_num);
907
- // Find the Gradio Textbox used as a trigger
908
- const triggerTextbox = document.getElementById('code-editor-update-trigger').querySelector('textarea');
909
- if (triggerTextbox) {{
910
- // Set its value to the received delta (JSON string)
911
- // This change event will be picked up by Gradio if a .change() listener is attached
912
- // However, directly manipulating the Ace editor instance is more reliable if possible.
913
- // For now, we assume the code_editor component handles incoming deltas internally
914
- // or provides a JS API. If not, this trigger approach is a fallback.
915
- triggerTextbox.value = JSON.stringify(data); // Pass full data
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.error('Code editor update trigger textbox not found.');
934
  }}
 
935
  }} else if (data.type === 'issues_updated') {{
936
  console.log('Received issues updated notification.');
937
- // Optionally trigger a refresh or show a notification
938
- // Example: Update status bar
939
- const statusBar = document.getElementById('status_output').querySelector('textarea');
940
  if (statusBar) {{
941
- statusBar.value = 'Issue list updated. Refresh may be needed.';
 
 
942
  statusBar.dispatchEvent(new Event('input', {{ bubbles: true }}));
943
  }}
944
- // More robust: Trigger the crawl button's click event via JS? (Can be complex)
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 if needed
 
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 (e.g., code changes)
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
- // This part is CRUCIAL and depends heavily on how the `code_editor`
976
- // component is implemented (e.g., using Ace Editor).
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
- // This needs to run *after* the Gradio component is rendered
985
- // and the editor is initialized. Using setTimeout is a common hack.
986
- setTimeout(() => {{
987
- const editorElement = document.querySelector('#code_editor_component'); // Find editor container
988
- // Find the actual Ace instance (this depends on the component's structure)
989
- // This is a GUESS - inspect the component's HTML/JS to find the correct way
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
- if (aceEditor) {{
999
- console.log('Ace Editor instance found. Attaching change listener.');
1000
- aceEditor.getSession().on('change', function(delta) {{
1001
- // Only send changes made by the user (ignore programmatic changes)
1002
- if (aceEditor.curOp && aceEditor.curOp.command.name) {{
1003
- console.log('Code changed by user:', delta);
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
- // Call setup after initial load and potentially after issue selection changes
1028
- // if the editor instance is recreated.
1029
- setupCodeEditorListener();
 
 
 
 
 
 
 
 
 
 
 
 
 
1030
 
1031
- // Re-attach listener if the editor component updates (e.g., on issue select)
1032
- // This requires observing changes to the component's container
1033
- const observer = new MutationObserver((mutationsList, observer) => {{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1034
  for(const mutation of mutationsList) {{
1035
- if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {{
1036
- // Check if the editor element was re-added/modified significantly
1037
  if (document.querySelector('#code_editor_component .ace_editor')) {{
1038
- console.log("Code editor component updated, re-attaching listener...");
1039
- setupCodeEditorListener();
1040
- break; // Assume we only need to re-attach once per mutation batch
 
 
 
 
 
1041
  }}
1042
  }}
1043
  }}
1044
  }});
1045
- const targetNode = document.getElementById('code_editor_component');
1046
- if(targetNode) {{
1047
- observer.observe(targetNode, {{ childList: true, subtree: true }});
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
- # Don't await broadcast here, let the periodic task handle it
 
 
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
- # Don't await broadcast here
 
 
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 disconnected: {websocket.remote_address} (Code: {e.code}, Reason: {e.reason})")
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 if client_id else websocket.remote_address}")
1115
- manager.ws_clients.remove(websocket)
1116
- if client_id and client_id in manager.collaborators:
1117
- del manager.collaborators[client_id] # Remove collaborator on disconnect
1118
- logger.info(f"Removed collaborator {client_id}.")
1119
- # Don't await broadcast here
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
- handler = lambda ws, path: handle_ws_connection(ws, path, manager)
1125
- async with websockets.serve(handler, "localhost", port):
1126
- logger.info(f"WebSocket server started on ws://localhost:{port}")
1127
- await asyncio.Future() # Run forever
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- httpd = HTTPServer(server_address, WebhookHandler)
1134
- logger.info(f"Webhook HTTP server started on port {port}")
1135
- httpd.serve_forever()
 
 
 
 
 
 
 
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 (Runs in main asyncio loop)
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
- await asyncio.gather(broadcast_task, websocket_server_task)
 
1157
 
1158
- # Run the asyncio tasks in a separate thread
1159
- asyncio_thread = threading.Thread(target=lambda: asyncio.run(main_async_tasks()), daemon=True)
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 in containers/networks
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
- logger.info("Gradio app launched. Webhook and WebSocket servers running in background.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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