E5K7 commited on
Commit
cd3b358
Β·
0 Parent(s):

πŸ”§ Initial commit: FixFlow β€” Autonomous Bug Resolution Agent

Browse files

- 5-step pipeline: Issue parsing β†’ Codebase mapping β†’ Root cause analysis β†’ Fix generation β†’ PR description
- GLM 5.1 (Z.ai) via OpenAI-compatible API with streaming support
- PyGithub integration: fetch issues, repo trees, file contents
- Python difflib unified diff generation
- Streamlit dark UI with glassmorphism design
- Confidence self-evaluation (optional)
- Export: full Markdown report + .diff patch
- Demo output for FastAPI issue included

.env.example ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FixFlow Environment Variables
2
+ # Copy this file to .env and fill in your API keys
3
+
4
+ # Z.ai / GLM API Key (get from https://open.bigmodel.cn/)
5
+ GLM_API_KEY=your_glm_api_key_here
6
+
7
+ # GitHub Personal Access Token (optional, for private repos & higher rate limits)
8
+ # Generate at: https://github.com/settings/tokens
9
+ GITHUB_TOKEN=your_github_token_here
10
+
11
+ # GLM Model (options: glm-5-plus, glm-4-plus, glm-4)
12
+ GLM_MODEL=glm-5-plus
13
+
14
+ # GLM API Base URL
15
+ GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
16
+
17
+ # Max files to analyze per repo
18
+ MAX_FILES_TO_SCAN=100
19
+
20
+ # Max file size in bytes to read (50KB default)
21
+ MAX_FILE_SIZE_BYTES=51200
.gitignore ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ venv/
16
+ env/
17
+ ENV/
18
+
19
+ # Environment variables
20
+ .env
21
+
22
+ # Streamlit
23
+ .streamlit/
24
+
25
+ # macOS
26
+ .DS_Store
27
+
28
+ # IDE
29
+ .idea/
30
+ .vscode/
31
+ *.swp
32
+ *.swo
33
+
34
+ # Logs
35
+ *.log
36
+
37
+ # Jupyter
38
+ .ipynb_checkpoints/
README.md ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # πŸ”§ FixFlow β€” Autonomous Bug Resolution Agent
2
+
3
+ <div align="center">
4
+
5
+ [![Python](https://img.shields.io/badge/Python-3.11+-3776AB?style=flat-square&logo=python&logoColor=white)](https://python.org)
6
+ [![Streamlit](https://img.shields.io/badge/Streamlit-1.30+-FF4B4B?style=flat-square&logo=streamlit&logoColor=white)](https://streamlit.io)
7
+ [![GLM 5.1](https://img.shields.io/badge/Powered%20by-GLM%205.1%20by%20Z.ai-6c63ff?style=flat-square)](https://open.bigmodel.cn)
8
+ [![License](https://img.shields.io/badge/License-MIT-10b981?style=flat-square)](LICENSE)
9
+
10
+ **Give FixFlow a GitHub issue. Get back a root cause analysis + a PR-ready fix.**
11
+
12
+ *Built with GLM 5.1 by Z.ai ⚑*
13
+
14
+ </div>
15
+
16
+ ---
17
+
18
+ ## ✨ Features
19
+
20
+ | Feature | Description |
21
+ |---------|-------------|
22
+ | πŸ› **Smart Issue Parsing** | Extracts error messages, reproduction steps, and technical clues from any GitHub issue |
23
+ | πŸ—ΊοΈ **Codebase Mapping** | Identifies the top 5-10 most suspect files from the entire repo tree |
24
+ | 🧠 **Chain-of-Thought Reasoning** | Traces execution flow step-by-step, citing file names, functions, and line numbers |
25
+ | πŸ”¬ **Root Cause Analysis** | Pinpoints the exact bug location with high-confidence reasoning |
26
+ | πŸ”§ **Fix Generation** | Generates minimal, precise code changes as unified diffs |
27
+ | πŸ“ **PR Description** | Writes a complete, reviewer-friendly pull request description |
28
+ | 🎯 **Confidence Score** | Optional self-evaluation step where GLM rates its own certainty |
29
+ | πŸ“€ **Export** | Download the full analysis report as Markdown or the patch as `.diff` |
30
+
31
+ ---
32
+
33
+ ## πŸš€ Quick Start
34
+
35
+ ### 1. Clone & Install
36
+
37
+ ```bash
38
+ git clone https://github.com/your-username/fixflow.git
39
+ cd fixflow
40
+
41
+ # Create virtual environment
42
+ python -m venv .venv
43
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
44
+
45
+ # Install dependencies
46
+ pip install -r requirements.txt
47
+ ```
48
+
49
+ ### 2. Configure API Keys
50
+
51
+ ```bash
52
+ cp .env.example .env
53
+ ```
54
+
55
+ Edit `.env`:
56
+
57
+ ```env
58
+ GLM_API_KEY=your_glm_api_key_here # Get from https://open.bigmodel.cn/
59
+ GITHUB_TOKEN=ghp_your_token_here # Optional, but recommended
60
+ GLM_MODEL=glm-5-plus
61
+ ```
62
+
63
+ ### 3. Run
64
+
65
+ ```bash
66
+ streamlit run app.py
67
+ ```
68
+
69
+ Open [http://localhost:8501](http://localhost:8501) πŸŽ‰
70
+
71
+ ---
72
+
73
+ ## πŸ”„ How It Works
74
+
75
+ ```
76
+ GitHub Issue URL
77
+ β”‚
78
+ β–Ό
79
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
80
+ β”‚ 1. Parse Issue β”‚ ─── Extract: error, repro steps, affected components
81
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
82
+ β”‚
83
+ β–Ό
84
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
85
+ β”‚ 2. Map Codebase β”‚ ─── Scan repo tree β†’ Rank top 5-10 suspect files
86
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
87
+ β”‚
88
+ β–Ό
89
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
90
+ β”‚ 3. Analyze Code β”‚ ─── Read files β†’ Chain-of-thought root cause tracing
91
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
92
+ β”‚
93
+ β–Ό
94
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
95
+ β”‚ 4. Generate Fix β”‚ ─── Produce corrected file versions (minimal changes)
96
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
97
+ β”‚
98
+ β–Ό
99
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
100
+ β”‚ 5. Write PR β”‚ ─── Unified diff + human-readable PR description
101
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
102
+ β”‚
103
+ β–Ό
104
+ πŸ“„ Full Report + πŸ“¦ Patch File
105
+ ```
106
+
107
+ ---
108
+
109
+ ## πŸ§ͺ Example Output
110
+
111
+ See [`demo/example_output.md`](demo/example_output.md) for a full sample analysis on a real FastAPI issue.
112
+
113
+ Quick preview:
114
+
115
+ ```
116
+ πŸ”¬ Root Cause:
117
+ In fastapi/_compat.py ~line 215, _get_value() calls model_dump()
118
+ without passing `include=include` in the Pydantic v2 branch.
119
+ The fix: add include=include, exclude=exclude to model_dump().
120
+ ```
121
+
122
+ ---
123
+
124
+ ## πŸ“ Project Structure
125
+
126
+ ```
127
+ fixflow/
128
+ β”œβ”€β”€ app.py # Streamlit frontend (dark UI, streaming output)
129
+ β”œβ”€β”€ backend/
130
+ β”‚ β”œβ”€β”€ __init__.py
131
+ β”‚ β”œβ”€β”€ config.py # API keys, model config, constants
132
+ β”‚ β”œβ”€β”€ github_client.py # Fetch issues, repo trees, file contents
133
+ β”‚ β”œβ”€β”€ code_indexer.py # Parse repo structure, format for LLM
134
+ β”‚ β”œβ”€β”€ agent.py # Core 5-step reasoning agent orchestrator
135
+ β”‚ β”œβ”€β”€ prompts.py # All LLM prompt templates
136
+ β”‚ β”œβ”€β”€ diff_generator.py # Generate unified diffs from proposed changes
137
+ β”‚ └── llm_client.py # GLM 5.1 API wrapper (sync + streaming)
138
+ β”œβ”€β”€ requirements.txt
139
+ β”œβ”€β”€ .env.example
140
+ β”œβ”€β”€ README.md
141
+ └── demo/
142
+ └── example_output.md # Sample output for showcase
143
+ ```
144
+
145
+ ---
146
+
147
+ ## βš™οΈ Configuration
148
+
149
+ | Variable | Default | Description |
150
+ |----------|---------|-------------|
151
+ | `GLM_API_KEY` | β€” | Your Z.ai API key (required) |
152
+ | `GITHUB_TOKEN` | β€” | GitHub PAT (optional, recommended) |
153
+ | `GLM_MODEL` | `glm-5-plus` | GLM model to use |
154
+ | `GLM_BASE_URL` | `https://open.bigmodel.cn/api/paas/v4` | API endpoint |
155
+ | `MAX_FILES_TO_SCAN` | `100` | Max files to include in repo scan |
156
+ | `MAX_FILE_SIZE_BYTES` | `51200` | Max file size to read (50 KB) |
157
+
158
+ ---
159
+
160
+ ## πŸ› οΈ Tech Stack
161
+
162
+ - **Frontend:** Streamlit with custom dark CSS (glassmorphism design)
163
+ - **Backend:** Python 3.11+, FastAPI-compatible architecture
164
+ - **LLM:** GLM 5.1 via Z.ai API (OpenAI-compatible endpoint)
165
+ - **GitHub:** PyGithub + GitHub REST API
166
+ - **Diffs:** Python `difflib` (unified diff format)
167
+
168
+ ---
169
+
170
+ ## πŸ“ License
171
+
172
+ MIT License β€” see [LICENSE](LICENSE) for details.
173
+
174
+ ---
175
+
176
+ <div align="center">
177
+ Built with ❀️ for the Z.ai GLM 5.1 Hackathon<br>
178
+ <b>Powered by GLM 5.1 by Z.ai ⚑</b>
179
+ </div>
app.py ADDED
@@ -0,0 +1,899 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FixFlow β€” Streamlit Frontend
3
+ Autonomous Bug Resolution Agent powered by GLM 5.1 (Z.ai)
4
+ """
5
+ import time
6
+ import logging
7
+ import threading
8
+ from typing import Optional
9
+
10
+ import streamlit as st
11
+
12
+ from backend.agent import AgentResult, FixFlowAgent, generate_full_report
13
+ from backend.config import GLM_MODEL, GLM_BASE_URL
14
+ from backend.github_client import GitHubClient
15
+ from backend.llm_client import GLMClient
16
+
17
+ # ── Logging Setup ─────────────────────────────────────────────────────────────
18
+ logging.basicConfig(
19
+ level=logging.INFO,
20
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
21
+ )
22
+ logger = logging.getLogger("fixflow.app")
23
+
24
+
25
+ # ── Page Config ───────────────────────────────────────────────────────────────
26
+ st.set_page_config(
27
+ page_title="FixFlow β€” Autonomous Bug Resolution Agent",
28
+ page_icon="πŸ”§",
29
+ layout="wide",
30
+ initial_sidebar_state="expanded",
31
+ )
32
+
33
+
34
+ # ── Custom CSS ────────────────────────────────────────────────────────────────
35
+ st.markdown("""
36
+ <style>
37
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
38
+
39
+ /* ── Root & Base ─────────── */
40
+ :root {
41
+ --bg-primary: #0a0b0f;
42
+ --bg-secondary: #12141a;
43
+ --bg-card: #1a1c24;
44
+ --bg-card-hover: #1e2028;
45
+ --accent-primary: #6c63ff;
46
+ --accent-secondary: #a78bfa;
47
+ --accent-green: #10b981;
48
+ --accent-red: #ef4444;
49
+ --accent-yellow: #f59e0b;
50
+ --accent-blue: #3b82f6;
51
+ --text-primary: #f0f0ff;
52
+ --text-secondary: #9ca3af;
53
+ --text-muted: #6b7280;
54
+ --border: #2a2c36;
55
+ --border-bright: #3a3c48;
56
+ --shadow-glow: 0 0 40px rgba(108, 99, 255, 0.15);
57
+ --radius: 12px;
58
+ --radius-sm: 8px;
59
+ }
60
+
61
+ /* Global font */
62
+ html, body, [class*="css"] {
63
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
64
+ color: var(--text-primary);
65
+ }
66
+
67
+ /* Dark background */
68
+ .stApp {
69
+ background: var(--bg-primary);
70
+ background-image: radial-gradient(ellipse at 20% 10%, rgba(108, 99, 255, 0.08) 0%, transparent 60%),
71
+ radial-gradient(ellipse at 80% 90%, rgba(167, 139, 250, 0.05) 0%, transparent 60%);
72
+ }
73
+
74
+ /* Sidebar */
75
+ section[data-testid="stSidebar"] {
76
+ background: var(--bg-secondary) !important;
77
+ border-right: 1px solid var(--border) !important;
78
+ }
79
+
80
+ section[data-testid="stSidebar"] > div {
81
+ padding: 1.5rem 1.2rem;
82
+ }
83
+
84
+ /* ── Logo / Header ───────── */
85
+ .fixflow-header {
86
+ text-align: center;
87
+ padding: 2rem 1rem 1rem;
88
+ margin-bottom: 1.5rem;
89
+ }
90
+
91
+ .fixflow-logo {
92
+ font-size: 3.5rem;
93
+ margin-bottom: 0.5rem;
94
+ display: block;
95
+ filter: drop-shadow(0 0 20px rgba(108, 99, 255, 0.5));
96
+ }
97
+
98
+ .fixflow-title {
99
+ font-size: 2.2rem;
100
+ font-weight: 800;
101
+ background: linear-gradient(135deg, #6c63ff 0%, #a78bfa 50%, #60a5fa 100%);
102
+ -webkit-background-clip: text;
103
+ -webkit-text-fill-color: transparent;
104
+ background-clip: text;
105
+ letter-spacing: -0.02em;
106
+ line-height: 1.2;
107
+ margin-bottom: 0.4rem;
108
+ }
109
+
110
+ .fixflow-subtitle {
111
+ color: var(--text-secondary);
112
+ font-size: 1rem;
113
+ font-weight: 400;
114
+ margin-bottom: 1rem;
115
+ }
116
+
117
+ .powered-badge {
118
+ display: inline-flex;
119
+ align-items: center;
120
+ gap: 0.4rem;
121
+ background: linear-gradient(135deg, rgba(108, 99, 255, 0.15), rgba(167, 139, 250, 0.1));
122
+ border: 1px solid rgba(108, 99, 255, 0.3);
123
+ border-radius: 100px;
124
+ padding: 0.3rem 0.9rem;
125
+ font-size: 0.78rem;
126
+ font-weight: 600;
127
+ color: var(--accent-secondary);
128
+ letter-spacing: 0.04em;
129
+ text-transform: uppercase;
130
+ }
131
+
132
+ /* ── Cards ───────────────── */
133
+ .pipeline-card {
134
+ background: var(--bg-card);
135
+ border: 1px solid var(--border);
136
+ border-radius: var(--radius);
137
+ padding: 1.5rem;
138
+ margin-bottom: 1rem;
139
+ transition: border-color 0.3s ease, box-shadow 0.3s ease;
140
+ position: relative;
141
+ overflow: hidden;
142
+ }
143
+
144
+ .pipeline-card::before {
145
+ content: '';
146
+ position: absolute;
147
+ top: 0; left: 0; right: 0;
148
+ height: 2px;
149
+ background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
150
+ opacity: 0;
151
+ transition: opacity 0.3s ease;
152
+ }
153
+
154
+ .pipeline-card:hover::before { opacity: 1; }
155
+ .pipeline-card:hover {
156
+ border-color: var(--border-bright);
157
+ box-shadow: var(--shadow-glow);
158
+ }
159
+
160
+ /* ── Step Status Indicators ─ */
161
+ .step-indicator {
162
+ display: flex;
163
+ align-items: center;
164
+ gap: 0.75rem;
165
+ padding: 0.8rem 1rem;
166
+ border-radius: var(--radius-sm);
167
+ margin-bottom: 0.5rem;
168
+ font-size: 0.9rem;
169
+ font-weight: 500;
170
+ border: 1px solid transparent;
171
+ transition: all 0.3s ease;
172
+ }
173
+
174
+ .step-idle {
175
+ background: rgba(107, 114, 128, 0.08);
176
+ border-color: rgba(107, 114, 128, 0.15);
177
+ color: var(--text-muted);
178
+ }
179
+
180
+ .step-running {
181
+ background: rgba(59, 130, 246, 0.1);
182
+ border-color: rgba(59, 130, 246, 0.3);
183
+ color: #60a5fa;
184
+ animation: pulse-blue 2s infinite;
185
+ }
186
+
187
+ .step-complete {
188
+ background: rgba(16, 185, 129, 0.08);
189
+ border-color: rgba(16, 185, 129, 0.25);
190
+ color: var(--accent-green);
191
+ }
192
+
193
+ .step-error {
194
+ background: rgba(239, 68, 68, 0.08);
195
+ border-color: rgba(239, 68, 68, 0.25);
196
+ color: var(--accent-red);
197
+ }
198
+
199
+ @keyframes pulse-blue {
200
+ 0%, 100% { opacity: 1; }
201
+ 50% { opacity: 0.7; }
202
+ }
203
+
204
+ .step-icon { font-size: 1.1rem; }
205
+ .step-time { margin-left: auto; font-size: 0.75rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
206
+
207
+ /* ── Input Fields ────────── */
208
+ .stTextInput > div > div > input,
209
+ .stTextArea > div > div > textarea {
210
+ background: var(--bg-card) !important;
211
+ border: 1px solid var(--border) !important;
212
+ border-radius: var(--radius-sm) !important;
213
+ color: var(--text-primary) !important;
214
+ font-family: 'Inter', sans-serif !important;
215
+ transition: border-color 0.2s !important;
216
+ }
217
+
218
+ .stTextInput > div > div > input:focus,
219
+ .stTextArea > div > div > textarea:focus {
220
+ border-color: var(--accent-primary) !important;
221
+ box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.12) !important;
222
+ }
223
+
224
+ /* ── Analyze Button ──────── */
225
+ .stButton > button[kind="primary"] {
226
+ background: linear-gradient(135deg, #6c63ff, #a78bfa) !important;
227
+ border: none !important;
228
+ border-radius: 10px !important;
229
+ font-family: 'Inter', sans-serif !important;
230
+ font-weight: 700 !important;
231
+ font-size: 1rem !important;
232
+ padding: 0.7rem 2rem !important;
233
+ letter-spacing: 0.02em !important;
234
+ transition: all 0.3s ease !important;
235
+ box-shadow: 0 4px 24px rgba(108, 99, 255, 0.35) !important;
236
+ color: white !important;
237
+ }
238
+
239
+ .stButton > button[kind="primary"]:hover {
240
+ transform: translateY(-2px) !important;
241
+ box-shadow: 0 8px 32px rgba(108, 99, 255, 0.5) !important;
242
+ }
243
+
244
+ .stButton > button[kind="primary"]:active {
245
+ transform: translateY(0) !important;
246
+ }
247
+
248
+ /* Secondary buttons */
249
+ .stButton > button[kind="secondary"] {
250
+ background: var(--bg-card) !important;
251
+ border: 1px solid var(--border-bright) !important;
252
+ border-radius: var(--radius-sm) !important;
253
+ color: var(--text-secondary) !important;
254
+ font-family: 'Inter', sans-serif !important;
255
+ font-weight: 500 !important;
256
+ }
257
+
258
+ /* ── Expander ────────────── */
259
+ .streamlit-expanderHeader {
260
+ background: var(--bg-card) !important;
261
+ border: 1px solid var(--border) !important;
262
+ border-radius: var(--radius-sm) !important;
263
+ color: var(--text-primary) !important;
264
+ font-weight: 600 !important;
265
+ transition: border-color 0.2s !important;
266
+ }
267
+
268
+ .streamlit-expanderHeader:hover {
269
+ border-color: var(--accent-primary) !important;
270
+ }
271
+
272
+ .streamlit-expanderContent {
273
+ background: var(--bg-secondary) !important;
274
+ border: 1px solid var(--border) !important;
275
+ border-top: none !important;
276
+ border-radius: 0 0 var(--radius-sm) var(--radius-sm) !important;
277
+ }
278
+
279
+ /* Code blocks */
280
+ .stCodeBlock pre, code {
281
+ font-family: 'JetBrains Mono', monospace !important;
282
+ font-size: 0.85rem !important;
283
+ }
284
+
285
+ /* ── Metrics ─────────────── */
286
+ .stat-card {
287
+ background: var(--bg-card);
288
+ border: 1px solid var(--border);
289
+ border-radius: var(--radius-sm);
290
+ padding: 1rem;
291
+ text-align: center;
292
+ }
293
+
294
+ .stat-value {
295
+ font-size: 1.8rem;
296
+ font-weight: 800;
297
+ background: linear-gradient(135deg, #6c63ff, #a78bfa);
298
+ -webkit-background-clip: text;
299
+ -webkit-text-fill-color: transparent;
300
+ background-clip: text;
301
+ line-height: 1;
302
+ }
303
+
304
+ .stat-label {
305
+ font-size: 0.75rem;
306
+ color: var(--text-muted);
307
+ margin-top: 0.3rem;
308
+ text-transform: uppercase;
309
+ letter-spacing: 0.06em;
310
+ }
311
+
312
+ /* ── Dividers ────────────── */
313
+ hr {
314
+ border: none !important;
315
+ border-top: 1px solid var(--border) !important;
316
+ margin: 1.5rem 0 !important;
317
+ }
318
+
319
+ /* ── Sidebar specific ────── */
320
+ .sidebar-section-title {
321
+ font-size: 0.7rem;
322
+ font-weight: 700;
323
+ text-transform: uppercase;
324
+ letter-spacing: 0.1em;
325
+ color: var(--text-muted);
326
+ margin: 1.2rem 0 0.5rem;
327
+ }
328
+
329
+ .sidebar-logo {
330
+ font-size: 1.5rem;
331
+ font-weight: 800;
332
+ background: linear-gradient(135deg, #6c63ff, #a78bfa);
333
+ -webkit-background-clip: text;
334
+ -webkit-text-fill-color: transparent;
335
+ background-clip: text;
336
+ margin-bottom: 0.2rem;
337
+ }
338
+
339
+ /* ── Stream output box ───── */
340
+ .stream-box {
341
+ background: var(--bg-secondary);
342
+ border: 1px solid var(--border);
343
+ border-radius: var(--radius-sm);
344
+ padding: 1rem;
345
+ font-family: 'JetBrains Mono', monospace;
346
+ font-size: 0.82rem;
347
+ line-height: 1.6;
348
+ color: #d1fae5;
349
+ max-height: 300px;
350
+ overflow-y: auto;
351
+ white-space: pre-wrap;
352
+ word-break: break-word;
353
+ }
354
+
355
+ /* ── Alerts ──────────────── */
356
+ .stAlert {
357
+ border-radius: var(--radius-sm) !important;
358
+ }
359
+
360
+ /* Toggle/checkbox */
361
+ .stCheckbox > label {
362
+ color: var(--text-secondary) !important;
363
+ font-size: 0.9rem !important;
364
+ }
365
+
366
+ /* Scrollbar */
367
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
368
+ ::-webkit-scrollbar-track { background: var(--bg-secondary); }
369
+ ::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 3px; }
370
+ ::-webkit-scrollbar-thumb:hover { background: var(--accent-primary); }
371
+
372
+ /* Selectbox */
373
+ .stSelectbox > div > div {
374
+ background: var(--bg-card) !important;
375
+ border-color: var(--border) !important;
376
+ color: var(--text-primary) !important;
377
+ }
378
+ </style>
379
+ """, unsafe_allow_html=True)
380
+
381
+
382
+ # ── Session State Init ────────────────────────────────────────────────────────
383
+ def init_session():
384
+ defaults = {
385
+ "result": None,
386
+ "running": False,
387
+ "step_statuses": {},
388
+ "step_messages": {},
389
+ "stream_buffer": "",
390
+ "error": None,
391
+ "glm_api_key": "",
392
+ "github_token": "",
393
+ "model": GLM_MODEL,
394
+ "run_confidence": False,
395
+ }
396
+ for k, v in defaults.items():
397
+ if k not in st.session_state:
398
+ st.session_state[k] = v
399
+
400
+ init_session()
401
+
402
+
403
+ # ── Sidebar ───────────────────────────────────────────────────────────────────
404
+ with st.sidebar:
405
+ st.markdown('<div class="sidebar-logo">πŸ”§ FixFlow</div>', unsafe_allow_html=True)
406
+ st.markdown('<div style="color: #6b7280; font-size: 0.8rem; margin-bottom: 1.5rem;">Autonomous Bug Resolution Agent</div>', unsafe_allow_html=True)
407
+
408
+ st.markdown('<div class="sidebar-section-title">πŸ”‘ API Configuration</div>', unsafe_allow_html=True)
409
+
410
+ glm_key = st.text_input(
411
+ "GLM API Key (Z.ai)",
412
+ value=st.session_state.glm_api_key,
413
+ type="password",
414
+ placeholder="Enter your Z.ai GLM API key...",
415
+ help="Get your key at https://open.bigmodel.cn/",
416
+ key="glm_key_input",
417
+ )
418
+ if glm_key:
419
+ st.session_state.glm_api_key = glm_key
420
+
421
+ github_token = st.text_input(
422
+ "GitHub Token (optional)",
423
+ value=st.session_state.github_token,
424
+ type="password",
425
+ placeholder="ghp_... (for private repos / higher limits)",
426
+ help="Needed for private repos. Also increases rate limit from 60 to 5000 req/hr.",
427
+ key="github_token_input",
428
+ )
429
+ if github_token:
430
+ st.session_state.github_token = github_token
431
+
432
+ st.markdown('<div class="sidebar-section-title">βš™οΈ Model Settings</div>', unsafe_allow_html=True)
433
+
434
+ model_choice = st.selectbox(
435
+ "GLM Model",
436
+ options=["glm-5-plus", "glm-4-plus", "glm-4"],
437
+ index=0,
438
+ key="model_select",
439
+ )
440
+ st.session_state.model = model_choice
441
+
442
+ st.markdown('<div class="sidebar-section-title">πŸ§ͺ Options</div>', unsafe_allow_html=True)
443
+
444
+ run_confidence = st.checkbox(
445
+ "Run confidence self-evaluation",
446
+ value=st.session_state.run_confidence,
447
+ help="Ask GLM to rate confidence in its own analysis (adds ~10-15s)",
448
+ key="confidence_check",
449
+ )
450
+ st.session_state.run_confidence = run_confidence
451
+
452
+ # Rate limit info
453
+ if st.session_state.github_token:
454
+ st.markdown('<div class="sidebar-section-title">πŸ“Š GitHub Status</div>', unsafe_allow_html=True)
455
+ try:
456
+ gh_temp = GitHubClient(token=st.session_state.github_token)
457
+ rl = gh_temp.get_rate_limit_info()
458
+ if rl:
459
+ remaining = rl.get("core_remaining", "?")
460
+ limit = rl.get("core_limit", "?")
461
+ pct = int(remaining / limit * 100) if isinstance(remaining, int) and isinstance(limit, int) else 0
462
+ color = "#10b981" if pct > 50 else "#f59e0b" if pct > 20 else "#ef4444"
463
+ st.markdown(
464
+ f'<div style="font-size:0.8rem; color: {color};">API: {remaining}/{limit} requests remaining</div>',
465
+ unsafe_allow_html=True
466
+ )
467
+ except Exception:
468
+ pass
469
+
470
+ st.markdown("---")
471
+ st.markdown(
472
+ '<div style="font-size: 0.72rem; color: #4b5563; line-height: 1.6;">'
473
+ 'πŸ”’ Your API keys are never stored or transmitted beyond direct API calls.<br><br>'
474
+ '⚑ Powered by <b style="color: #a78bfa;">GLM 5.1 by Z.ai</b>'
475
+ '</div>',
476
+ unsafe_allow_html=True
477
+ )
478
+
479
+
480
+ # ── Main Content ──────────────────────────────────────────────────────────────
481
+
482
+ # Header
483
+ st.markdown("""
484
+ <div class="fixflow-header">
485
+ <span class="fixflow-logo">πŸ”§</span>
486
+ <div class="fixflow-title">FixFlow</div>
487
+ <div class="fixflow-subtitle">Autonomous Bug Resolution Agent</div>
488
+ <span class="powered-badge">⚑ GLM 5.1 by Z.ai</span>
489
+ </div>
490
+ """, unsafe_allow_html=True)
491
+
492
+
493
+ # ── Input Section ─────────────────────────────────────────────────────────────
494
+ st.markdown('<div class="pipeline-card">', unsafe_allow_html=True)
495
+ st.markdown("### 🎯 Analyze a GitHub Issue")
496
+ st.markdown('<div style="color: #9ca3af; font-size: 0.9rem; margin-bottom: 1rem;">Paste a GitHub issue URL and the repository to analyze. FixFlow will autonomously trace the root cause and generate a fix.</div>', unsafe_allow_html=True)
497
+
498
+ col1, col2 = st.columns(2)
499
+ with col1:
500
+ issue_url = st.text_input(
501
+ "GitHub Issue URL",
502
+ placeholder="https://github.com/owner/repo/issues/123",
503
+ help="Full URL to the GitHub issue you want to fix",
504
+ key="issue_url_input",
505
+ )
506
+ with col2:
507
+ repo_url = st.text_input(
508
+ "Repository URL",
509
+ placeholder="https://github.com/owner/repo",
510
+ help="The repository containing the buggy code",
511
+ key="repo_url_input",
512
+ )
513
+
514
+ # Auto-fill repo from issue URL
515
+ if issue_url and not repo_url:
516
+ # Try to extract repo from issue URL
517
+ import re
518
+ m = re.match(r"(https://github\.com/[^/]+/[^/]+)/issues/\d+", issue_url.strip())
519
+ if m:
520
+ st.session_state["repo_url_input"] = m.group(1)
521
+ repo_url = m.group(1)
522
+
523
+ # Example buttons
524
+ st.markdown('<div style="margin-top: 0.5rem; color: #6b7280; font-size: 0.8rem;">πŸ’‘ Try with an example:</div>', unsafe_allow_html=True)
525
+ ex_col1, ex_col2, ex_col3 = st.columns(3)
526
+ with ex_col1:
527
+ if st.button("FastAPI #1234 example", key="ex1", help="Example issue"):
528
+ st.info("Set issue URL to a real FastAPI issue, e.g.: https://github.com/tiangolo/fastapi/issues/10876")
529
+ with ex_col2:
530
+ if st.button("Requests #6710 example", key="ex2", help="Example issue"):
531
+ st.info("Set issue URL to: https://github.com/psf/requests/issues/6710")
532
+ with ex_col3:
533
+ if st.button("Flask #5742 example", key="ex3", help="Example issue"):
534
+ st.info("Set issue URL to: https://github.com/pallets/flask/issues/5742")
535
+
536
+ st.markdown('</div>', unsafe_allow_html=True)
537
+
538
+ # ── Analyze Button ────────────────────────────────────────────────────────────
539
+ st.markdown("<br>", unsafe_allow_html=True)
540
+ btn_col, info_col = st.columns([1, 3])
541
+
542
+ with btn_col:
543
+ analyze_clicked = st.button(
544
+ "πŸš€ Analyze & Fix",
545
+ key="analyze_btn",
546
+ type="primary",
547
+ disabled=st.session_state.running,
548
+ use_container_width=True,
549
+ )
550
+
551
+ with info_col:
552
+ if st.session_state.running:
553
+ st.markdown(
554
+ '<div style="padding: 0.6rem; color: #60a5fa; font-size: 0.9rem; display: flex; align-items: center; gap: 0.5rem;">'
555
+ '⏳ Analysis in progress... This may take 1-3 minutes depending on repo size.'
556
+ '</div>',
557
+ unsafe_allow_html=True
558
+ )
559
+ elif st.session_state.result:
560
+ total_time = sum(st.session_state.result.step_timings.values())
561
+ st.markdown(
562
+ f'<div style="padding: 0.6rem; color: #10b981; font-size: 0.9rem;">'
563
+ f'βœ… Last analysis completed in {total_time:.1f}s</div>',
564
+ unsafe_allow_html=True
565
+ )
566
+
567
+
568
+ # ── Pipeline Execution ────────────────────────────────────────────────────────
569
+ STEP_LABELS = {
570
+ "0_fetch": ("πŸ“‘", "Fetching GitHub Data"),
571
+ "1_issue": ("πŸ“‹", "Analyzing Bug Report"),
572
+ "2_mapping": ("πŸ—ΊοΈ", "Mapping Codebase"),
573
+ "3_analysis": ("πŸ”¬", "Root Cause Analysis"),
574
+ "4_fix": ("πŸ”§", "Generating Fix"),
575
+ "5_diff": ("πŸ“", "Creating PR Description"),
576
+ "6_confidence": ("🎯", "Confidence Evaluation"),
577
+ }
578
+
579
+
580
+ def run_agent():
581
+ """Execute the FixFlow agent pipeline (runs in main thread for Streamlit)."""
582
+ st.session_state.running = True
583
+ st.session_state.result = None
584
+ st.session_state.error = None
585
+ st.session_state.step_statuses = {}
586
+ st.session_state.step_messages = {}
587
+ st.session_state.stream_buffer = ""
588
+
589
+ def on_status(step: str, status: str, message: str):
590
+ st.session_state.step_statuses[step] = status
591
+ st.session_state.step_messages[step] = message
592
+
593
+ def on_stream(chunk: str):
594
+ st.session_state.stream_buffer += chunk
595
+
596
+ try:
597
+ llm = GLMClient(
598
+ api_key=st.session_state.glm_api_key,
599
+ base_url=GLM_BASE_URL,
600
+ model=st.session_state.model,
601
+ )
602
+ gh = GitHubClient(token=st.session_state.github_token or None)
603
+ agent = FixFlowAgent(llm_client=llm, github_client=gh)
604
+
605
+ result = agent.run(
606
+ issue_url=issue_url.strip(),
607
+ repo_url=repo_url.strip(),
608
+ on_status=on_status,
609
+ stream_callback=on_stream,
610
+ run_confidence_eval=st.session_state.run_confidence,
611
+ )
612
+ st.session_state.result = result
613
+
614
+ except Exception as e:
615
+ st.session_state.error = str(e)
616
+ logger.exception("Agent pipeline error")
617
+ finally:
618
+ st.session_state.running = False
619
+
620
+
621
+ # Trigger on button click
622
+ if analyze_clicked:
623
+ if not st.session_state.glm_api_key:
624
+ st.error("⚠️ Please enter your GLM API key in the sidebar.")
625
+ elif not issue_url:
626
+ st.error("⚠️ Please enter a GitHub Issue URL.")
627
+ elif not repo_url:
628
+ st.error("⚠️ Please enter the Repository URL.")
629
+ else:
630
+ run_agent()
631
+ st.rerun()
632
+
633
+
634
+ # ── Error Display ─────────────────────────────────────────────────────────────
635
+ if st.session_state.error:
636
+ st.error(f"❌ **Error:** {st.session_state.error}")
637
+ with st.expander("πŸ› Debug Information"):
638
+ st.code(st.session_state.error)
639
+
640
+
641
+ # ── Pipeline Progress ─────────────────────────────────────────────────────────
642
+ if st.session_state.step_statuses or st.session_state.result:
643
+ st.markdown("---")
644
+ st.markdown("### ⚑ Pipeline Progress")
645
+
646
+ statuses = st.session_state.step_statuses
647
+ result: Optional[AgentResult] = st.session_state.result
648
+ timings = result.step_timings if result else {}
649
+
650
+ status_icons = {
651
+ "running": "⏳",
652
+ "complete": "βœ…",
653
+ "error": "❌",
654
+ "info": "ℹ️",
655
+ }
656
+
657
+ progress_cols = st.columns(min(len(STEP_LABELS), 4))
658
+ step_items = list(STEP_LABELS.items())
659
+
660
+ for i, (step_id, (icon, label)) in enumerate(step_items):
661
+ status = statuses.get(step_id, "idle")
662
+ timing = timings.get(step_id)
663
+
664
+ css_class = f"step-{status}" if status != "idle" else "step-idle"
665
+ status_icon = status_icons.get(status, "⬜")
666
+ time_str = f"{timing:.1f}s" if timing else ""
667
+
668
+ st.markdown(
669
+ f'<div class="step-indicator {css_class}">'
670
+ f'<span class="step-icon">{status_icon}</span>'
671
+ f'<span>{icon} {label}</span>'
672
+ f'<span class="step-time">{time_str}</span>'
673
+ f'</div>',
674
+ unsafe_allow_html=True,
675
+ )
676
+
677
+
678
+ # ── Results ───────────────────────────────────────────────────────────────────
679
+ if st.session_state.result:
680
+ result: AgentResult = st.session_state.result
681
+ st.markdown("---")
682
+
683
+ # ── Summary Stats ─────────────────────────────────────────────────────────
684
+ total_time = sum(result.step_timings.values())
685
+ stats = result.diff_stats
686
+
687
+ st.markdown("### πŸ“Š Analysis Summary")
688
+ m1, m2, m3, m4 = st.columns(4)
689
+
690
+ with m1:
691
+ st.markdown(
692
+ f'<div class="stat-card">'
693
+ f'<div class="stat-value">{len(result.suspect_file_paths)}</div>'
694
+ f'<div class="stat-label">Files Analyzed</div>'
695
+ f'</div>',
696
+ unsafe_allow_html=True
697
+ )
698
+ with m2:
699
+ st.markdown(
700
+ f'<div class="stat-card">'
701
+ f'<div class="stat-value">{stats.get("files_changed", 0)}</div>'
702
+ f'<div class="stat-label">Files Changed</div>'
703
+ f'</div>',
704
+ unsafe_allow_html=True
705
+ )
706
+ with m3:
707
+ st.markdown(
708
+ f'<div class="stat-card">'
709
+ f'<div class="stat-value">+{stats.get("lines_added", 0)}</div>'
710
+ f'<div class="stat-label">Lines Added</div>'
711
+ f'</div>',
712
+ unsafe_allow_html=True
713
+ )
714
+ with m4:
715
+ st.markdown(
716
+ f'<div class="stat-card">'
717
+ f'<div class="stat-value">{total_time:.0f}s</div>'
718
+ f'<div class="stat-label">Total Time</div>'
719
+ f'</div>',
720
+ unsafe_allow_html=True
721
+ )
722
+
723
+ st.markdown("<br>", unsafe_allow_html=True)
724
+
725
+ # ── Step 1: Bug Summary ───────────────────────────────────────────────────
726
+ with st.expander("πŸ“‹ Step 1: Bug Summary", expanded=True):
727
+ st.markdown(
728
+ f'<div style="color: #9ca3af; font-size: 0.82rem; margin-bottom: 1rem;">'
729
+ f'⏱️ Completed in {result.step_timings.get("1_issue", 0):.1f}s'
730
+ f'</div>',
731
+ unsafe_allow_html=True
732
+ )
733
+ st.markdown(result.bug_summary)
734
+
735
+ # ── Step 2: Relevant Files ────────────────────────────────────────────────
736
+ with st.expander("πŸ” Step 2: Relevant Files & Codebase Mapping", expanded=False):
737
+ st.markdown(
738
+ f'<div style="color: #9ca3af; font-size: 0.82rem; margin-bottom: 0.5rem;">'
739
+ f'⏱️ Completed in {result.step_timings.get("2_mapping", 0):.1f}s | '
740
+ f'Selected {len(result.suspect_file_paths)} files for deep analysis'
741
+ f'</div>',
742
+ unsafe_allow_html=True
743
+ )
744
+
745
+ if result.suspect_file_paths:
746
+ st.markdown("**🎯 Files Selected for Analysis:**")
747
+ for i, fp in enumerate(result.suspect_file_paths, 1):
748
+ st.markdown(f"`{i}.` `{fp}`")
749
+
750
+ st.markdown("---")
751
+ st.markdown(result.relevant_files_analysis)
752
+
753
+ # ── Step 3: Root Cause Analysis ───────────────────────────────────────────
754
+ with st.expander("πŸ”¬ Step 3: Root Cause Analysis (Chain-of-Thought)", expanded=True):
755
+ st.markdown(
756
+ f'<div style="color: #9ca3af; font-size: 0.82rem; margin-bottom: 1rem;">'
757
+ f'⏱️ Completed in {result.step_timings.get("3_analysis", 0):.1f}s | '
758
+ f'This is the core reasoning chain β€” read carefully!'
759
+ f'</div>',
760
+ unsafe_allow_html=True
761
+ )
762
+ st.markdown(result.root_cause_analysis)
763
+
764
+ # ── Step 4: Proposed Fix (Diff) ───────────────────────────────────────────
765
+ with st.expander("πŸ”§ Step 4: Proposed Fix", expanded=True):
766
+ st.markdown(
767
+ f'<div style="color: #9ca3af; font-size: 0.82rem; margin-bottom: 1rem;">'
768
+ f'⏱️ Completed in {result.step_timings.get("4_fix", 0):.1f}s | '
769
+ f'{stats.get("files_changed", 0)} file(s) modified, '
770
+ f'+{stats.get("lines_added", 0)} / -{stats.get("lines_removed", 0)} lines'
771
+ f'</div>',
772
+ unsafe_allow_html=True
773
+ )
774
+
775
+ if result.diffs:
776
+ # Syntax-highlighted diff
777
+ for filepath, diff_content in result.diffs.items():
778
+ st.markdown(f"**`{filepath}`**")
779
+ st.code(diff_content, language="diff")
780
+ else:
781
+ st.warning("⚠️ No diffs generated. The LLM may not have proposed direct file changes.")
782
+ if result.fix_generation_raw:
783
+ st.markdown("**Raw fix proposal from GLM:**")
784
+ st.markdown(result.fix_generation_raw)
785
+
786
+ # Copy button for full diff
787
+ if result.diff_formatted and result.diffs:
788
+ st.markdown("---")
789
+ copy_col, _ = st.columns([1, 3])
790
+ with copy_col:
791
+ st.download_button(
792
+ "πŸ“‹ Copy Full Diff",
793
+ data=result.diff_formatted,
794
+ file_name="fixflow.diff",
795
+ mime="text/plain",
796
+ use_container_width=True,
797
+ )
798
+
799
+ # ── Step 5: Fix Explanation ───────────────────────────────────────────────
800
+ with st.expander("πŸ“ Step 5: PR Description & Fix Explanation", expanded=True):
801
+ st.markdown(
802
+ f'<div style="color: #9ca3af; font-size: 0.82rem; margin-bottom: 1rem;">'
803
+ f'⏱️ Completed in {result.step_timings.get("5_diff", 0):.1f}s'
804
+ f'</div>',
805
+ unsafe_allow_html=True
806
+ )
807
+ st.markdown(result.fix_explanation)
808
+
809
+ # ── Confidence Eval (optional) ────────────────────────────────────────────
810
+ if result.confidence_eval:
811
+ with st.expander("🎯 Confidence Self-Evaluation", expanded=False):
812
+ st.markdown(result.confidence_eval)
813
+
814
+ # ── Export Full Report ────────────────────────────────────────────────────
815
+ st.markdown("---")
816
+ st.markdown("### πŸ“€ Export Report")
817
+ exp_col1, exp_col2, _ = st.columns([1, 1, 2])
818
+
819
+ full_report = generate_full_report(result)
820
+ issue_num = result.issue_data.get("number", "0")
821
+ repo_slug = repo_url.strip().rstrip("/").split("/")[-1] if repo_url else "repo"
822
+
823
+ with exp_col1:
824
+ st.download_button(
825
+ "πŸ“„ Download Full Report (.md)",
826
+ data=full_report,
827
+ file_name=f"fixflow_{repo_slug}_issue_{issue_num}.md",
828
+ mime="text/markdown",
829
+ use_container_width=True,
830
+ )
831
+
832
+ with exp_col2:
833
+ if result.diff_formatted and result.diffs:
834
+ st.download_button(
835
+ "πŸ“¦ Download Patch (.diff)",
836
+ data=result.diff_formatted,
837
+ file_name=f"fixflow_{repo_slug}_issue_{issue_num}.diff",
838
+ mime="text/plain",
839
+ use_container_width=True,
840
+ )
841
+
842
+ st.markdown("---")
843
+ st.markdown(
844
+ '<div style="text-align: center; color: #4b5563; font-size: 0.8rem; padding: 1rem 0;">'
845
+ 'πŸ”§ <b style="color: #6c63ff;">FixFlow</b> β€” Autonomous Bug Resolution Β· Powered by '
846
+ '<b style="color: #a78bfa;">GLM 5.1 by Z.ai</b>'
847
+ '</div>',
848
+ unsafe_allow_html=True
849
+ )
850
+
851
+
852
+ # ── Empty State ───────────────────────────────────────────────────────────────
853
+ elif not st.session_state.running and not st.session_state.error:
854
+ st.markdown("<br>", unsafe_allow_html=True)
855
+
856
+ col1, col2, col3 = st.columns(3)
857
+
858
+ cards = [
859
+ ("πŸ›", "Bug Report Parsing", "Automatically extracts error messages, reproduction steps, affected components, and technical clues from any GitHub issue."),
860
+ ("🧠", "Chain-of-Thought Reasoning", "Traces the execution flow step-by-step, citing specific file names, functions, and line numbers to pinpoint the root cause."),
861
+ ("πŸ”§", "PR-Ready Fixes", "Generates minimal, precise code fixes with unified diffs and a complete pull request description you can copy directly."),
862
+ ]
863
+
864
+ for col, (icon, title, desc) in zip([col1, col2, col3], cards):
865
+ with col:
866
+ st.markdown(
867
+ f'<div class="pipeline-card" style="text-align: center; padding: 2rem 1.5rem;">'
868
+ f'<div style="font-size: 2.5rem; margin-bottom: 0.75rem;">{icon}</div>'
869
+ f'<div style="font-weight: 700; font-size: 1rem; color: #f0f0ff; margin-bottom: 0.5rem;">{title}</div>'
870
+ f'<div style="font-size: 0.85rem; color: #6b7280; line-height: 1.6;">{desc}</div>'
871
+ f'</div>',
872
+ unsafe_allow_html=True,
873
+ )
874
+
875
+ st.markdown("<br>", unsafe_allow_html=True)
876
+
877
+ # How it works
878
+ st.markdown("### πŸ”„ How It Works")
879
+ steps_html = """
880
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.75rem; margin-top: 1rem;">
881
+ """
882
+ how_steps = [
883
+ ("1", "πŸ“‘", "Fetch Issue", "Pulls the full GitHub issue: title, body, comments, labels"),
884
+ ("2", "πŸ—ΊοΈ", "Map Codebase", "Identifies top 5-10 suspect files from the repo tree"),
885
+ ("3", "πŸ”¬", "Analyze Code", "Deep code reading with chain-of-thought root cause tracing"),
886
+ ("4", "πŸ”§", "Generate Fix", "Creates corrected file versions with minimal changes"),
887
+ ("5", "πŸ“", "Write PR", "Produces unified diff + human-readable PR description"),
888
+ ]
889
+ for num, icon, title, desc in how_steps:
890
+ steps_html += f"""
891
+ <div style="background: #12141a; border: 1px solid #2a2c36; border-radius: 10px; padding: 1rem; position: relative;">
892
+ <div style="position: absolute; top: -10px; left: 12px; background: linear-gradient(135deg, #6c63ff, #a78bfa); border-radius: 50%; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; font-weight: 800; color: white;">{num}</div>
893
+ <div style="font-size: 1.4rem; margin-bottom: 0.4rem; margin-top: 0.3rem;">{icon}</div>
894
+ <div style="font-weight: 700; font-size: 0.9rem; color: #f0f0ff; margin-bottom: 0.3rem;">{title}</div>
895
+ <div style="font-size: 0.78rem; color: #6b7280; line-height: 1.5;">{desc}</div>
896
+ </div>
897
+ """
898
+ steps_html += "</div>"
899
+ st.markdown(steps_html, unsafe_allow_html=True)
backend/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # FixFlow Backend Package
backend/agent.py ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FixFlow Core Agent β€” Multi-step autonomous bug resolution pipeline.
3
+
4
+ Pipeline:
5
+ Step 1: Issue Understanding β†’ Structured bug summary
6
+ Step 2: Codebase Mapping β†’ Ranked list of suspect files
7
+ Step 3: Deep Code Analysis β†’ Root cause analysis + reasoning chain
8
+ Step 4: Fix Generation β†’ Corrected file contents
9
+ Step 5: Diff & Explanation β†’ PR-ready diff + human explanation
10
+ """
11
+ import logging
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from typing import Callable, Dict, Iterator, List, Optional
15
+
16
+ from backend.config import MAX_FILES_TO_ANALYZE
17
+ from backend.llm_client import GLMClient
18
+ from backend.github_client import GitHubClient
19
+ from backend.code_indexer import (
20
+ build_file_tree_string,
21
+ extract_file_paths_from_llm_response,
22
+ extract_keywords_from_issue,
23
+ format_file_contents_for_prompt,
24
+ rank_files_by_keyword_match,
25
+ )
26
+ from backend.diff_generator import (
27
+ format_diff_for_display,
28
+ generate_all_diffs,
29
+ get_diff_stats,
30
+ parse_fixed_files_from_llm_response,
31
+ )
32
+ from backend.prompts import (
33
+ SYSTEM_MESSAGE,
34
+ ISSUE_ANALYSIS_PROMPT,
35
+ FILE_RELEVANCE_PROMPT,
36
+ ROOT_CAUSE_PROMPT,
37
+ FIX_GENERATION_PROMPT,
38
+ FIX_EXPLANATION_PROMPT,
39
+ CONFIDENCE_EVAL_PROMPT,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ # ── Result Dataclass ──────────────────────────────────────────────────────────
46
+
47
+ @dataclass
48
+ class AgentResult:
49
+ """Holds all outputs from the FixFlow pipeline."""
50
+ # Inputs
51
+ issue_url: str = ""
52
+ repo_url: str = ""
53
+ issue_data: Dict = field(default_factory=dict)
54
+
55
+ # Step outputs
56
+ bug_summary: str = ""
57
+ relevant_files_analysis: str = ""
58
+ suspect_file_paths: List[str] = field(default_factory=list)
59
+ root_cause_analysis: str = ""
60
+ fix_generation_raw: str = ""
61
+ fixed_files: Dict[str, str] = field(default_factory=dict)
62
+ diffs: Dict[str, str] = field(default_factory=dict)
63
+ diff_formatted: str = ""
64
+ fix_explanation: str = ""
65
+ confidence_eval: str = ""
66
+
67
+ # Metadata
68
+ step_timings: Dict[str, float] = field(default_factory=dict)
69
+ step_errors: Dict[str, str] = field(default_factory=dict)
70
+ diff_stats: Dict = field(default_factory=dict)
71
+ file_tree: List[Dict] = field(default_factory=list)
72
+ original_file_contents: Dict[str, str] = field(default_factory=dict)
73
+
74
+
75
+ # Status callback type
76
+ StatusCallback = Optional[Callable[[str, str, str], None]]
77
+ # Args: (step_name, status, message)
78
+ # status: "running" | "complete" | "error" | "info"
79
+
80
+
81
+ # ── FixFlow Agent ─────────────────────────────────────────────────────────────
82
+
83
+ class FixFlowAgent:
84
+ """
85
+ Orchestrates the full bug-resolution pipeline.
86
+
87
+ Usage:
88
+ agent = FixFlowAgent(glm_client, github_client)
89
+ result = agent.run(issue_url, repo_url, on_status=callback)
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ llm_client: GLMClient,
95
+ github_client: GitHubClient,
96
+ ):
97
+ self.llm = llm_client
98
+ self.gh = github_client
99
+
100
+ # ── Public entry point ────────────────────────────────────────────────────
101
+
102
+ def run(
103
+ self,
104
+ issue_url: str,
105
+ repo_url: str,
106
+ on_status: StatusCallback = None,
107
+ stream_callback: Optional[Callable[[str], None]] = None,
108
+ run_confidence_eval: bool = False,
109
+ ) -> AgentResult:
110
+ """
111
+ Execute the full FixFlow pipeline. Returns an AgentResult.
112
+
113
+ Args:
114
+ issue_url: Full GitHub issue URL
115
+ repo_url: Full GitHub repo URL
116
+ on_status: Optional callback(step, status, message) for UI updates
117
+ stream_callback: Optional callback(chunk) for streaming LLM output
118
+ run_confidence_eval: Whether to run the optional confidence self-eval
119
+ """
120
+ result = AgentResult(issue_url=issue_url, repo_url=repo_url)
121
+ self._status = on_status or (lambda *a: None)
122
+
123
+ try:
124
+ # ── Step 0: Fetch GitHub data ─────────────────────────────────
125
+ self._emit("0_fetch", "running", "Fetching GitHub issue and repository data...")
126
+ t0 = time.time()
127
+
128
+ result.issue_data = self._fetch_issue(issue_url)
129
+ result.file_tree = self._fetch_repo_tree(repo_url)
130
+ result.step_timings["0_fetch"] = time.time() - t0
131
+ self._emit("0_fetch", "complete",
132
+ f"Fetched issue #{result.issue_data['number']} + "
133
+ f"{len(result.file_tree)} repo files in "
134
+ f"{result.step_timings['0_fetch']:.1f}s")
135
+
136
+ # ── Step 1: Issue Understanding ───────────────────────────────
137
+ self._emit("1_issue", "running", "Analyzing bug report with GLM...")
138
+ t1 = time.time()
139
+
140
+ result.bug_summary = self._step1_issue_understanding(
141
+ result.issue_data, stream_callback
142
+ )
143
+ result.step_timings["1_issue"] = time.time() - t1
144
+ self._emit("1_issue", "complete",
145
+ f"Bug analysis complete in {result.step_timings['1_issue']:.1f}s")
146
+
147
+ # ── Step 2: Codebase Mapping ──────────────────────────────────
148
+ self._emit("2_mapping", "running", "Scanning codebase to identify suspect files...")
149
+ t2 = time.time()
150
+
151
+ result.relevant_files_analysis, result.suspect_file_paths = \
152
+ self._step2_codebase_mapping(
153
+ result.bug_summary,
154
+ result.file_tree,
155
+ result.issue_data,
156
+ stream_callback,
157
+ repo_url=repo_url,
158
+ )
159
+ result.step_timings["2_mapping"] = time.time() - t2
160
+ self._emit("2_mapping", "complete",
161
+ f"Identified {len(result.suspect_file_paths)} suspect files in "
162
+ f"{result.step_timings['2_mapping']:.1f}s")
163
+
164
+ # ── Step 3: Deep Code Analysis ────────────────────────────────
165
+ self._emit("3_analysis", "running",
166
+ f"Reading {len(result.suspect_file_paths)} files + performing root cause analysis...")
167
+ t3 = time.time()
168
+
169
+ result.original_file_contents = self.gh.fetch_multiple_files(
170
+ repo_url, result.suspect_file_paths
171
+ )
172
+ result.root_cause_analysis = self._step3_deep_analysis(
173
+ result.bug_summary,
174
+ result.original_file_contents,
175
+ stream_callback,
176
+ )
177
+ result.step_timings["3_analysis"] = time.time() - t3
178
+ self._emit("3_analysis", "complete",
179
+ f"Root cause identified in {result.step_timings['3_analysis']:.1f}s")
180
+
181
+ # ── Step 4: Fix Generation ────────────────────────────────────
182
+ self._emit("4_fix", "running", "Generating corrected file contents...")
183
+ t4 = time.time()
184
+
185
+ result.fix_generation_raw = self._step4_fix_generation(
186
+ result.root_cause_analysis,
187
+ result.original_file_contents,
188
+ stream_callback,
189
+ )
190
+ result.fixed_files = parse_fixed_files_from_llm_response(
191
+ result.fix_generation_raw,
192
+ result.suspect_file_paths,
193
+ )
194
+ result.step_timings["4_fix"] = time.time() - t4
195
+ self._emit("4_fix", "complete",
196
+ f"Generated fixes for {len(result.fixed_files)} file(s) in "
197
+ f"{result.step_timings['4_fix']:.1f}s")
198
+
199
+ # ── Step 5: Diff & Explanation ────────────────────────────────
200
+ self._emit("5_diff", "running", "Generating diff and PR explanation...")
201
+ t5 = time.time()
202
+
203
+ result.diffs = generate_all_diffs(
204
+ result.original_file_contents, result.fixed_files
205
+ )
206
+ result.diff_formatted = format_diff_for_display(result.diffs)
207
+ result.diff_stats = get_diff_stats(result.diffs)
208
+
209
+ result.fix_explanation = self._step5_explanation(
210
+ result.bug_summary,
211
+ result.root_cause_analysis,
212
+ result.diff_formatted,
213
+ stream_callback,
214
+ )
215
+ result.step_timings["5_diff"] = time.time() - t5
216
+ self._emit("5_diff", "complete",
217
+ f"PR explanation ready in {result.step_timings['5_diff']:.1f}s")
218
+
219
+ # ── Optional: Confidence Evaluation ───────────────────────────
220
+ if run_confidence_eval:
221
+ self._emit("6_confidence", "running", "Running self-evaluation...")
222
+ t6 = time.time()
223
+ combined = (
224
+ f"# Bug Summary\n{result.bug_summary}\n\n"
225
+ f"# Root Cause\n{result.root_cause_analysis}\n\n"
226
+ f"# Fix Explanation\n{result.fix_explanation}"
227
+ )
228
+ result.confidence_eval = self._run_confidence_eval(combined)
229
+ result.step_timings["6_confidence"] = time.time() - t6
230
+ self._emit("6_confidence", "complete",
231
+ f"Confidence eval done in {result.step_timings['6_confidence']:.1f}s")
232
+
233
+ except Exception as e:
234
+ logger.exception("FixFlow pipeline failed")
235
+ step = self._current_step or "unknown"
236
+ result.step_errors[step] = str(e)
237
+ self._emit(step, "error", f"❌ Pipeline failed: {e}")
238
+ raise
239
+
240
+ return result
241
+
242
+ # ── Pipeline Steps ────────────────────────────────────────────────────────
243
+
244
+ def _step1_issue_understanding(
245
+ self,
246
+ issue_data: Dict,
247
+ stream_cb: Optional[Callable] = None,
248
+ ) -> str:
249
+ self._current_step = "1_issue"
250
+
251
+ comments_text = ""
252
+ for c in issue_data.get("comments", [])[:5]:
253
+ comments_text += f"**@{c['author']}:** {c['body'][:500]}\n\n"
254
+ if not comments_text:
255
+ comments_text = "No comments."
256
+
257
+ prompt = ISSUE_ANALYSIS_PROMPT.format(
258
+ title=issue_data.get("title", ""),
259
+ body=issue_data.get("body", ""),
260
+ labels=", ".join(issue_data.get("labels", [])) or "none",
261
+ comments=comments_text,
262
+ )
263
+
264
+ messages = [
265
+ {"role": "system", "content": SYSTEM_MESSAGE},
266
+ {"role": "user", "content": prompt},
267
+ ]
268
+ return self._llm_call(messages, stream_cb, temperature=0.2)
269
+
270
+ def _step2_codebase_mapping(
271
+ self,
272
+ bug_summary: str,
273
+ file_tree: List[Dict],
274
+ issue_data: Dict,
275
+ stream_cb: Optional[Callable] = None,
276
+ repo_url: str = "",
277
+ ):
278
+ self._current_step = "2_mapping"
279
+
280
+ # Pre-filter files by keyword match for large repos
281
+ keywords = extract_keywords_from_issue(issue_data)
282
+ ranked_files = rank_files_by_keyword_match(file_tree, keywords)
283
+
284
+ tree_string = build_file_tree_string(ranked_files, max_lines=200)
285
+ repo_name = repo_url.rstrip("/").split("/")[-2:]
286
+ repo_display = "/".join(repo_name) if len(repo_name) == 2 else repo_url
287
+
288
+ prompt = FILE_RELEVANCE_PROMPT.format(
289
+ bug_summary=bug_summary,
290
+ file_tree=tree_string,
291
+ repo_name=repo_display,
292
+ )
293
+
294
+ messages = [
295
+ {"role": "system", "content": SYSTEM_MESSAGE},
296
+ {"role": "user", "content": prompt},
297
+ ]
298
+ analysis = self._llm_call(messages, stream_cb, temperature=0.2)
299
+
300
+ # Extract actual file paths from the response
301
+ paths = extract_file_paths_from_llm_response(analysis)
302
+
303
+ # Validate against actual tree (only keep paths that exist)
304
+ known_paths = {f["path"] for f in file_tree}
305
+ valid_paths = [p for p in paths if p in known_paths]
306
+
307
+ # If LLM hallucinated paths, fall back to keyword-ranked files
308
+ if not valid_paths:
309
+ logger.warning("LLM returned no valid paths β€” falling back to keyword ranking")
310
+ valid_paths = [f["path"] for f in ranked_files[:MAX_FILES_TO_ANALYZE]]
311
+
312
+ return analysis, valid_paths[:MAX_FILES_TO_ANALYZE]
313
+
314
+ def _step3_deep_analysis(
315
+ self,
316
+ bug_summary: str,
317
+ file_contents: Dict[str, str],
318
+ stream_cb: Optional[Callable] = None,
319
+ ) -> str:
320
+ self._current_step = "3_analysis"
321
+
322
+ formatted = format_file_contents_for_prompt(file_contents)
323
+
324
+ prompt = ROOT_CAUSE_PROMPT.format(
325
+ bug_summary=bug_summary,
326
+ file_contents=formatted,
327
+ )
328
+
329
+ messages = [
330
+ {"role": "system", "content": SYSTEM_MESSAGE},
331
+ {"role": "user", "content": prompt},
332
+ ]
333
+ return self._llm_call(messages, stream_cb, temperature=0.15, max_tokens=6000)
334
+
335
+ def _step4_fix_generation(
336
+ self,
337
+ root_cause: str,
338
+ file_contents: Dict[str, str],
339
+ stream_cb: Optional[Callable] = None,
340
+ ) -> str:
341
+ self._current_step = "4_fix"
342
+
343
+ formatted = format_file_contents_for_prompt(file_contents)
344
+
345
+ # Build list of filepaths for the placeholder
346
+ filepaths = ", ".join(file_contents.keys()) or "affected_file.py"
347
+
348
+ prompt = FIX_GENERATION_PROMPT.format(
349
+ root_cause=root_cause,
350
+ file_contents=formatted,
351
+ filepath_placeholder=filepaths,
352
+ )
353
+
354
+ messages = [
355
+ {"role": "system", "content": SYSTEM_MESSAGE},
356
+ {"role": "user", "content": prompt},
357
+ ]
358
+ return self._llm_call(messages, stream_cb, temperature=0.1, max_tokens=8000)
359
+
360
+ def _step5_explanation(
361
+ self,
362
+ bug_summary: str,
363
+ root_cause: str,
364
+ diff_formatted: str,
365
+ stream_cb: Optional[Callable] = None,
366
+ ) -> str:
367
+ self._current_step = "5_diff"
368
+
369
+ # Shorten root cause for context
370
+ root_cause_summary = root_cause[:2000] + ("..." if len(root_cause) > 2000 else "")
371
+
372
+ prompt = FIX_EXPLANATION_PROMPT.format(
373
+ bug_summary=bug_summary,
374
+ root_cause_summary=root_cause_summary,
375
+ unified_diff=diff_formatted[:3000],
376
+ )
377
+
378
+ messages = [
379
+ {"role": "system", "content": SYSTEM_MESSAGE},
380
+ {"role": "user", "content": prompt},
381
+ ]
382
+ return self._llm_call(messages, stream_cb, temperature=0.3)
383
+
384
+ def _run_confidence_eval(self, analysis: str) -> str:
385
+ self._current_step = "6_confidence"
386
+ prompt = CONFIDENCE_EVAL_PROMPT.format(analysis=analysis[:4000])
387
+ messages = [
388
+ {"role": "system", "content": SYSTEM_MESSAGE},
389
+ {"role": "user", "content": prompt},
390
+ ]
391
+ return self._llm_call(messages, None, temperature=0.2)
392
+
393
+ # ── Helpers ───────────────────────────────────────────────────────────────
394
+
395
+ def _llm_call(
396
+ self,
397
+ messages: List[Dict],
398
+ stream_cb: Optional[Callable],
399
+ temperature: float = 0.3,
400
+ max_tokens: int = 4096,
401
+ ) -> str:
402
+ """
403
+ Route to streaming or sync call depending on whether a stream callback is provided.
404
+ """
405
+ if stream_cb:
406
+ full_response = ""
407
+ for chunk in self.llm.chat_stream(messages, temperature, max_tokens):
408
+ stream_cb(chunk)
409
+ full_response += chunk
410
+ return full_response
411
+ else:
412
+ return self.llm.chat(messages, temperature, max_tokens)
413
+
414
+ def _fetch_issue(self, issue_url: str) -> Dict:
415
+ return self.gh.fetch_issue(issue_url)
416
+
417
+ def _fetch_repo_tree(self, repo_url: str) -> List[Dict]:
418
+ return self.gh.fetch_repo_tree(repo_url)
419
+
420
+ def _emit(self, step: str, status: str, message: str) -> None:
421
+ self._status(step, status, message)
422
+ logger.info("[%s] %s: %s", step, status.upper(), message)
423
+
424
+ _current_step: str = "init"
425
+
426
+
427
+ # ── Wrapper for full report generation ───────────────────────────────────────
428
+
429
+ def generate_full_report(result: AgentResult) -> str:
430
+ """
431
+ Generate a complete markdown report from an AgentResult.
432
+ Suitable for download/export.
433
+ """
434
+ total_time = sum(result.step_timings.values())
435
+ stats = result.diff_stats
436
+
437
+ report = f"""# πŸ”§ FixFlow Autonomous Bug Resolution Report
438
+
439
+ **Issue:** [{result.issue_data.get('title', 'Unknown')}]({result.issue_url})
440
+ **Repository:** {result.repo_url}
441
+ **Analysis Date:** {time.strftime('%Y-%m-%d %H:%M UTC')}
442
+ **Total Analysis Time:** {total_time:.1f}s
443
+
444
+ ---
445
+
446
+ ## πŸ“‹ Step 1: Bug Summary
447
+
448
+ {result.bug_summary}
449
+
450
+ ---
451
+
452
+ ## πŸ” Step 2: Relevant Files Analysis
453
+
454
+ {result.relevant_files_analysis}
455
+
456
+ **Files Selected for Analysis:**
457
+ {chr(10).join(f'- `{p}`' for p in result.suspect_file_paths)}
458
+
459
+ ---
460
+
461
+ ## πŸ”¬ Step 3: Root Cause Analysis
462
+
463
+ {result.root_cause_analysis}
464
+
465
+ ---
466
+
467
+ ## πŸ”§ Step 4: Proposed Fix
468
+
469
+ **Diff Statistics:**
470
+ - Files changed: {stats.get('files_changed', 0)}
471
+ - Lines added: +{stats.get('lines_added', 0)}
472
+ - Lines removed: -{stats.get('lines_removed', 0)}
473
+
474
+ {result.diff_formatted}
475
+
476
+ ---
477
+
478
+ ## πŸ“ Step 5: Fix Explanation (PR Description)
479
+
480
+ {result.fix_explanation}
481
+
482
+ ---
483
+
484
+ {f"## 🎯 Confidence Evaluation{chr(10)}{result.confidence_eval}{chr(10)}{chr(10)}---{chr(10)}" if result.confidence_eval else ""}
485
+
486
+ ## ⏱️ Timing Breakdown
487
+
488
+ | Step | Duration |
489
+ |------|----------|
490
+ {"".join(f"| {k} | {v:.1f}s |{chr(10)}" for k, v in result.step_timings.items())}
491
+
492
+ ---
493
+ *Generated by FixFlow β€” Autonomous Bug Resolution Agent powered by GLM 5.1*
494
+ """
495
+ return report
backend/code_indexer.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Code indexer: parses repo structure and helps identify the most
3
+ relevant files for a given bug. No vector DB β€” pure in-memory.
4
+ """
5
+ import logging
6
+ import re
7
+ from typing import List, Dict, Optional
8
+
9
+ from backend.config import CODE_EXTENSIONS, MAX_FILES_TO_ANALYZE
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def build_file_tree_string(files: List[Dict], max_lines: int = 300) -> str:
15
+ """
16
+ Convert a flat list of file dicts into an indented tree string
17
+ suitable for LLM context.
18
+ """
19
+ paths = sorted(f["path"] for f in files)
20
+
21
+ lines = []
22
+ prev_parts: List[str] = []
23
+
24
+ for path in paths:
25
+ parts = path.split("/")
26
+ # Find the common prefix depth
27
+ common = 0
28
+ for i, (a, b) in enumerate(zip(prev_parts, parts[:-1])):
29
+ if a == b:
30
+ common = i + 1
31
+ else:
32
+ break
33
+
34
+ # Print changed directory levels
35
+ for depth in range(common, len(parts) - 1):
36
+ indent = " " * depth
37
+ lines.append(f"{indent}πŸ“ {parts[depth]}/")
38
+
39
+ indent = " " * (len(parts) - 1)
40
+ lines.append(f"{indent}πŸ“„ {parts[-1]}")
41
+ prev_parts = parts[:-1]
42
+
43
+ if len(lines) >= max_lines:
44
+ lines.append(f"... and more files ({len(paths) - paths.index(path) - 1} remaining)")
45
+ break
46
+
47
+ return "\n".join(lines)
48
+
49
+
50
+ def format_file_contents_for_prompt(
51
+ file_contents: Dict[str, str],
52
+ max_chars_per_file: int = 3000,
53
+ max_total_chars: int = 20000,
54
+ ) -> str:
55
+ """
56
+ Format multiple file contents into a single block for LLM context.
57
+ Truncates long files and respects a total character budget.
58
+ """
59
+ sections = []
60
+ total_chars = 0
61
+
62
+ for path, content in file_contents.items():
63
+ if total_chars >= max_total_chars:
64
+ sections.append(f"[Remaining files omitted due to context limit]")
65
+ break
66
+
67
+ # Add line numbers for reference
68
+ lines = content.splitlines()
69
+ numbered = "\n".join(
70
+ f"{i+1:4d} | {line}" for i, line in enumerate(lines)
71
+ )
72
+
73
+ if len(numbered) > max_chars_per_file:
74
+ truncated = numbered[:max_chars_per_file]
75
+ # Find a clean line boundary
76
+ last_newline = truncated.rfind("\n")
77
+ if last_newline > 0:
78
+ truncated = truncated[:last_newline]
79
+ numbered = truncated + f"\n\n... [TRUNCATED β€” {len(lines)} total lines, showing first {truncated.count(chr(10))} lines]"
80
+
81
+ section = f"### File: `{path}`\n```\n{numbered}\n```"
82
+ sections.append(section)
83
+ total_chars += len(section)
84
+
85
+ return "\n\n".join(sections)
86
+
87
+
88
+ def extract_file_paths_from_llm_response(response: str) -> List[str]:
89
+ """
90
+ Parse file paths from the LLM's relevance ranking response.
91
+ Looks for backtick-quoted paths like `path/to/file.py` or **`path/to/file.py`**.
92
+ """
93
+ # Match paths in backticks
94
+ patterns = [
95
+ r"`([a-zA-Z0-9_\-./]+\.[a-zA-Z]+)`", # `path/to/file.ext`
96
+ r"\*\*`([a-zA-Z0-9_\-./]+\.[a-zA-Z]+)`\*\*", # **`path`**
97
+ ]
98
+ paths = []
99
+ for pattern in patterns:
100
+ found = re.findall(pattern, response)
101
+ for p in found:
102
+ if p not in paths and "/" in p or "." in p:
103
+ paths.append(p)
104
+
105
+ return paths[:MAX_FILES_TO_ANALYZE]
106
+
107
+
108
+ def rank_files_by_keyword_match(
109
+ files: List[Dict],
110
+ keywords: List[str],
111
+ ) -> List[Dict]:
112
+ """
113
+ Quick keyword-based pre-filter before sending the full list to the LLM.
114
+ Returns files sorted by keyword match count (descending).
115
+ """
116
+ scored = []
117
+ lc_keywords = [kw.lower() for kw in keywords]
118
+
119
+ for f in files:
120
+ path_lower = f["path"].lower()
121
+ score = sum(kw in path_lower for kw in lc_keywords)
122
+ scored.append((score, f))
123
+
124
+ scored.sort(key=lambda x: -x[0])
125
+ return [f for _, f in scored]
126
+
127
+
128
+ def extract_keywords_from_issue(issue_data: Dict) -> List[str]:
129
+ """
130
+ Extract potential code-relevant keywords from an issue dict.
131
+ Used for pre-filtering before sending to LLM.
132
+ """
133
+ text = " ".join([
134
+ issue_data.get("title", ""),
135
+ issue_data.get("body", ""),
136
+ ]).lower()
137
+
138
+ # Extract likely identifiers: CamelCase, snake_case, module names
139
+ words = re.findall(r"\b[a-zA-Z][a-zA-Z0-9_]{2,}\b", text)
140
+ # Deduplicate while preserving order
141
+ seen = set()
142
+ keywords = []
143
+ for w in words:
144
+ lw = w.lower()
145
+ if lw not in seen and len(lw) > 3:
146
+ seen.add(lw)
147
+ keywords.append(lw)
148
+
149
+ return keywords[:30]
150
+
151
+
152
+ def get_file_summary(path: str, content: str, max_chars: int = 500) -> str:
153
+ """
154
+ Generate a quick summary of a file (first N chars of meaningful content).
155
+ Skips blank lines and comment-only lines at the top.
156
+ """
157
+ lines = content.splitlines()
158
+ meaningful = []
159
+ for line in lines:
160
+ stripped = line.strip()
161
+ if stripped and not stripped.startswith("#") and not stripped.startswith("//"):
162
+ meaningful.append(line)
163
+ if len("\n".join(meaningful)) > max_chars:
164
+ break
165
+ preview = "\n".join(meaningful)[:max_chars]
166
+ return preview
backend/config.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FixFlow Configuration
3
+ All API keys, model config, and constants loaded from environment variables.
4
+ """
5
+ import os
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv()
9
+
10
+ # ── LLM Config ──────────────────────────────────────────────────────────────
11
+ GLM_API_KEY: str = os.getenv("GLM_API_KEY", "")
12
+ GLM_BASE_URL: str = os.getenv("GLM_BASE_URL", "https://open.bigmodel.cn/api/paas/v4")
13
+ GLM_MODEL: str = os.getenv("GLM_MODEL", "glm-5-plus")
14
+
15
+ # ── GitHub Config ────────────────────────────────────────────────────────────
16
+ GITHUB_TOKEN: str = os.getenv("GITHUB_TOKEN", "")
17
+
18
+ # ── Agent Limits ─────────────────────────────────────────────────────────────
19
+ MAX_FILES_TO_SCAN: int = int(os.getenv("MAX_FILES_TO_SCAN", "100"))
20
+ MAX_FILE_SIZE_BYTES: int = int(os.getenv("MAX_FILE_SIZE_BYTES", "51200")) # 50 KB
21
+ MAX_FILES_TO_ANALYZE: int = 10 # Top N files sent to deep analysis
22
+ MAX_REPO_FILES: int = 500 # Hard cap on tree traversal
23
+
24
+ # ── File Filters (skip these in code analysis) ───────────────────────────────
25
+ IGNORE_EXTENSIONS = {
26
+ ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp",
27
+ ".mp4", ".mp3", ".wav", ".pdf", ".zip", ".tar", ".gz",
28
+ ".woff", ".woff2", ".ttf", ".eot",
29
+ ".lock", ".sum", ".mod",
30
+ ".pyc", ".pyo", ".pyd",
31
+ ".class", ".jar",
32
+ ".DS_Store",
33
+ }
34
+
35
+ IGNORE_DIRS = {
36
+ "node_modules", ".git", ".github", "__pycache__", ".venv", "venv",
37
+ "env", "dist", "build", ".next", ".nuxt", "coverage", ".pytest_cache",
38
+ "vendor", "third_party", "external", "site-packages",
39
+ }
40
+
41
+ CODE_EXTENSIONS = {
42
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rb", ".rs",
43
+ ".cpp", ".c", ".h", ".hpp", ".cs", ".php", ".swift", ".kt", ".scala",
44
+ ".sh", ".bash", ".yaml", ".yml", ".toml", ".cfg", ".ini", ".env",
45
+ ".json", ".xml", ".html", ".css", ".scss", ".sql", ".md",
46
+ }
47
+
48
+ # ── Timing & Logging ─────────────────────────────────────────────────────────
49
+ LOG_LLM_CALLS: bool = os.getenv("LOG_LLM_CALLS", "true").lower() == "true"
backend/diff_generator.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Diff generator: creates unified diffs from original vs. fixed file contents.
3
+ """
4
+ import difflib
5
+ import logging
6
+ from typing import Dict, List, Tuple
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def generate_unified_diff(
12
+ original_content: str,
13
+ fixed_content: str,
14
+ filename: str,
15
+ context_lines: int = 5,
16
+ ) -> str:
17
+ """
18
+ Generate a unified diff between two versions of a file.
19
+ Returns the diff as a string.
20
+ """
21
+ original_lines = original_content.splitlines(keepends=True)
22
+ fixed_lines = fixed_content.splitlines(keepends=True)
23
+
24
+ diff = difflib.unified_diff(
25
+ original_lines,
26
+ fixed_lines,
27
+ fromfile=f"a/{filename}",
28
+ tofile=f"b/{filename}",
29
+ n=context_lines,
30
+ )
31
+ return "".join(diff)
32
+
33
+
34
+ def generate_all_diffs(
35
+ original_files: Dict[str, str],
36
+ fixed_files: Dict[str, str],
37
+ ) -> Dict[str, str]:
38
+ """
39
+ Generate unified diffs for all changed files.
40
+ Returns {filepath: diff_string}.
41
+ Only includes files that actually changed.
42
+ """
43
+ diffs = {}
44
+
45
+ for filepath, fixed_content in fixed_files.items():
46
+ original = original_files.get(filepath, "")
47
+
48
+ # Normalize line endings for comparison
49
+ orig_normalized = original.replace("\r\n", "\n").strip()
50
+ fixed_normalized = fixed_content.replace("\r\n", "\n").strip()
51
+
52
+ if orig_normalized == fixed_normalized:
53
+ logger.info("No changes in %s β€” skipping diff", filepath)
54
+ continue
55
+
56
+ diff = generate_unified_diff(
57
+ original,
58
+ fixed_content,
59
+ filepath,
60
+ )
61
+
62
+ if diff.strip():
63
+ diffs[filepath] = diff
64
+ changed_lines = _count_changed_lines(diff)
65
+ logger.info(
66
+ "Generated diff for %s: +%d -%d lines",
67
+ filepath, changed_lines[0], changed_lines[1],
68
+ )
69
+
70
+ return diffs
71
+
72
+
73
+ def _count_changed_lines(diff: str) -> Tuple[int, int]:
74
+ """Count added and removed lines in a unified diff."""
75
+ added = sum(1 for line in diff.splitlines() if line.startswith("+") and not line.startswith("+++"))
76
+ removed = sum(1 for line in diff.splitlines() if line.startswith("-") and not line.startswith("---"))
77
+ return added, removed
78
+
79
+
80
+ def format_diff_for_display(diffs: Dict[str, str]) -> str:
81
+ """
82
+ Format all diffs into a single markdown code block for display.
83
+ """
84
+ if not diffs:
85
+ return "No changes generated."
86
+
87
+ parts = []
88
+ for filepath, diff in diffs.items():
89
+ added, removed = _count_changed_lines(diff)
90
+ parts.append(
91
+ f"### `{filepath}` (+{added} / -{removed} lines)\n"
92
+ f"```diff\n{diff}\n```"
93
+ )
94
+
95
+ return "\n\n".join(parts)
96
+
97
+
98
+ def parse_fixed_files_from_llm_response(
99
+ response: str,
100
+ suspect_files: List[str],
101
+ ) -> Dict[str, str]:
102
+ """
103
+ Parse the LLM's fix generation response to extract {filepath: content}.
104
+
105
+ The LLM is asked to output:
106
+ ### Fix for `path/to/file.py`
107
+ ```python
108
+ <full file content>
109
+ ```
110
+
111
+ This function extracts those code blocks.
112
+ """
113
+ import re
114
+
115
+ fixed_files = {}
116
+
117
+ # Pattern: ### Fix for `filepath` ... ```lang\n<content>\n```
118
+ pattern = re.compile(
119
+ r"Fix for `([^`]+)`.*?```(?:\w+)?\n(.*?)```",
120
+ re.DOTALL | re.IGNORECASE,
121
+ )
122
+
123
+ for match in pattern.finditer(response):
124
+ filepath = match.group(1).strip()
125
+ content = match.group(2)
126
+
127
+ # Clean up the content
128
+ content = content.rstrip()
129
+
130
+ # Verify the filepath looks reasonable
131
+ if "/" in filepath or "." in filepath:
132
+ fixed_files[filepath] = content
133
+ logger.info("Parsed fixed content for: %s (%d chars)", filepath, len(content))
134
+
135
+ # Fallback: try to match any filepath from suspect_files
136
+ if not fixed_files:
137
+ logger.warning("Could not parse fix blocks from LLM response β€” trying fallback")
138
+ for fp in suspect_files:
139
+ # Look for content near the filename mention
140
+ escaped = re.escape(fp)
141
+ m = re.search(
142
+ escaped + r".*?```(?:\w+)?\n(.*?)```",
143
+ response,
144
+ re.DOTALL,
145
+ )
146
+ if m:
147
+ fixed_files[fp] = m.group(1).rstrip()
148
+
149
+ return fixed_files
150
+
151
+
152
+ def get_diff_stats(diffs: Dict[str, str]) -> Dict:
153
+ """Return aggregate stats about the diffs."""
154
+ total_added = 0
155
+ total_removed = 0
156
+ for diff in diffs.values():
157
+ a, r = _count_changed_lines(diff)
158
+ total_added += a
159
+ total_removed += r
160
+
161
+ return {
162
+ "files_changed": len(diffs),
163
+ "lines_added": total_added,
164
+ "lines_removed": total_removed,
165
+ }
backend/github_client.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GitHub client for fetching issues, repo trees, and file contents.
3
+ Supports both public repos (no auth) and private repos (with token).
4
+ """
5
+ import re
6
+ import logging
7
+ from typing import Dict, List, Optional, Tuple
8
+ from urllib.parse import urlparse
9
+
10
+ import requests
11
+ from github import Github, GithubException, Auth
12
+
13
+ from backend.config import (
14
+ GITHUB_TOKEN,
15
+ IGNORE_EXTENSIONS,
16
+ IGNORE_DIRS,
17
+ CODE_EXTENSIONS,
18
+ MAX_FILE_SIZE_BYTES,
19
+ MAX_REPO_FILES,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # ── URL Parsing Helpers ───────────────────────────────────────────────────────
26
+
27
+ def parse_issue_url(issue_url: str) -> Tuple[str, str, int]:
28
+ """
29
+ Parse a GitHub issue URL into (owner, repo, issue_number).
30
+ Supports:
31
+ https://github.com/owner/repo/issues/123
32
+ """
33
+ issue_url = issue_url.strip().rstrip("/")
34
+ pattern = r"github\.com/([^/]+)/([^/]+)/issues/(\d+)"
35
+ match = re.search(pattern, issue_url)
36
+ if not match:
37
+ raise ValueError(
38
+ f"Could not parse GitHub issue URL: {issue_url!r}\n"
39
+ "Expected format: https://github.com/owner/repo/issues/123"
40
+ )
41
+ owner, repo, issue_num = match.groups()
42
+ return owner, repo, int(issue_num)
43
+
44
+
45
+ def parse_repo_url(repo_url: str) -> Tuple[str, str]:
46
+ """
47
+ Parse a GitHub repo URL into (owner, repo).
48
+ Supports:
49
+ https://github.com/owner/repo
50
+ https://github.com/owner/repo.git
51
+ """
52
+ repo_url = repo_url.strip().rstrip("/").removesuffix(".git")
53
+ pattern = r"github\.com/([^/]+)/([^/]+)"
54
+ match = re.search(pattern, repo_url)
55
+ if not match:
56
+ raise ValueError(
57
+ f"Could not parse GitHub repo URL: {repo_url!r}\n"
58
+ "Expected format: https://github.com/owner/repo"
59
+ )
60
+ owner, repo = match.groups()
61
+ return owner, repo
62
+
63
+
64
+ # ── GitHub Client ─────────────────────────────────────────────────────────────
65
+
66
+ class GitHubClient:
67
+ """Wraps PyGithub for FixFlow's use cases."""
68
+
69
+ def __init__(self, token: Optional[str] = None):
70
+ tok = token or GITHUB_TOKEN
71
+ if tok:
72
+ auth = Auth.Token(tok)
73
+ self._gh = Github(auth=auth)
74
+ else:
75
+ self._gh = Github() # unauthenticated (60 req/hr)
76
+ self._rate_limit_warned = False
77
+
78
+ # ── Issue Fetching ────────────────────────────────────────────────────────
79
+
80
+ def fetch_issue(self, issue_url: str) -> Dict:
81
+ """
82
+ Fetch a GitHub issue and return a structured dict:
83
+ {title, body, labels, state, author, comments, url}
84
+ """
85
+ owner, repo_name, issue_num = parse_issue_url(issue_url)
86
+ logger.info("Fetching issue #%d from %s/%s", issue_num, owner, repo_name)
87
+
88
+ try:
89
+ repo = self._gh.get_repo(f"{owner}/{repo_name}")
90
+ issue = repo.get_issue(number=issue_num)
91
+ except GithubException as e:
92
+ raise RuntimeError(
93
+ f"Failed to fetch issue from GitHub: {e.data.get('message', str(e))}"
94
+ ) from e
95
+
96
+ # Collect top comments (up to 10)
97
+ comments = []
98
+ try:
99
+ for comment in issue.get_comments():
100
+ comments.append({
101
+ "author": comment.user.login if comment.user else "unknown",
102
+ "body": comment.body or "",
103
+ "created_at": str(comment.created_at),
104
+ })
105
+ if len(comments) >= 10:
106
+ break
107
+ except GithubException:
108
+ pass
109
+
110
+ return {
111
+ "title": issue.title or "",
112
+ "body": issue.body or "",
113
+ "labels": [lbl.name for lbl in issue.labels],
114
+ "state": issue.state,
115
+ "author": issue.user.login if issue.user else "unknown",
116
+ "url": issue.html_url,
117
+ "number": issue_num,
118
+ "comments": comments,
119
+ "repo_owner": owner,
120
+ "repo_name": repo_name,
121
+ }
122
+
123
+ # ── Repo Tree ─────────────────────────────────────────────────────────────
124
+
125
+ def fetch_repo_tree(
126
+ self,
127
+ repo_url: str,
128
+ token: Optional[str] = None,
129
+ ) -> List[Dict]:
130
+ """
131
+ Return a flat list of code files in the repo.
132
+ Each entry: {path, size, type}
133
+ Filters out binary files, ignored dirs, etc.
134
+ """
135
+ owner, repo_name = parse_repo_url(repo_url)
136
+ logger.info("Fetching repo tree for %s/%s", owner, repo_name)
137
+
138
+ # Refresh client if a token was provided on this call
139
+ if token and not GITHUB_TOKEN:
140
+ auth = Auth.Token(token)
141
+ self._gh = Github(auth=auth)
142
+
143
+ try:
144
+ repo = self._gh.get_repo(f"{owner}/{repo_name}")
145
+ # Use recursive git tree for efficiency
146
+ tree = repo.get_git_tree("HEAD", recursive=True)
147
+ except GithubException as e:
148
+ raise RuntimeError(
149
+ f"Failed to fetch repo tree: {e.data.get('message', str(e))}"
150
+ ) from e
151
+
152
+ files = []
153
+ for item in tree.tree:
154
+ if item.type != "blob":
155
+ continue
156
+ path = item.path
157
+
158
+ # Skip ignored directories
159
+ parts = path.split("/")
160
+ if any(p in IGNORE_DIRS for p in parts[:-1]):
161
+ continue
162
+
163
+ # Skip ignored/non-code extensions
164
+ ext = "." + path.rsplit(".", 1)[-1].lower() if "." in path else ""
165
+ if ext in IGNORE_EXTENSIONS:
166
+ continue
167
+ if ext not in CODE_EXTENSIONS and ext:
168
+ continue
169
+
170
+ # Skip overly large files
171
+ size = item.size or 0
172
+ if size > MAX_FILE_SIZE_BYTES:
173
+ logger.debug("Skipping large file (%d bytes): %s", size, path)
174
+ continue
175
+
176
+ files.append({"path": path, "size": size, "type": item.type})
177
+ if len(files) >= MAX_REPO_FILES:
178
+ logger.warning("Hit MAX_REPO_FILES limit (%d)", MAX_REPO_FILES)
179
+ break
180
+
181
+ logger.info("Found %d code files in %s/%s", len(files), owner, repo_name)
182
+ return files
183
+
184
+ # ── File Content ──────────────────────────────────────────────────────────
185
+
186
+ def fetch_file_content(
187
+ self,
188
+ repo_url: str,
189
+ file_path: str,
190
+ ) -> str:
191
+ """
192
+ Fetch the raw text content of a single file from the repo.
193
+ Returns empty string on failure (binary, too large, etc).
194
+ """
195
+ owner, repo_name = parse_repo_url(repo_url)
196
+ try:
197
+ repo = self._gh.get_repo(f"{owner}/{repo_name}")
198
+ content_obj = repo.get_contents(file_path)
199
+ # Handle list (shouldn't happen for blobs, but defensive)
200
+ if isinstance(content_obj, list):
201
+ content_obj = content_obj[0]
202
+ if content_obj.size > MAX_FILE_SIZE_BYTES:
203
+ return f"[File too large to display: {content_obj.size} bytes]"
204
+ decoded = content_obj.decoded_content
205
+ return decoded.decode("utf-8", errors="replace")
206
+ except GithubException as e:
207
+ logger.warning("Could not fetch %s: %s", file_path, e)
208
+ return ""
209
+ except Exception as e:
210
+ logger.warning("Error decoding %s: %s", file_path, e)
211
+ return ""
212
+
213
+ def fetch_multiple_files(
214
+ self,
215
+ repo_url: str,
216
+ file_paths: List[str],
217
+ ) -> Dict[str, str]:
218
+ """
219
+ Fetch contents of multiple files. Returns {path: content} dict.
220
+ """
221
+ result = {}
222
+ owner, repo_name = parse_repo_url(repo_url)
223
+ logger.info("Fetching %d files from %s/%s", len(file_paths), owner, repo_name)
224
+
225
+ for path in file_paths:
226
+ content = self.fetch_file_content(repo_url, path)
227
+ if content:
228
+ result[path] = content
229
+ return result
230
+
231
+ # ── Rate Limit Info ───────────────────────────────────────────────────────
232
+
233
+ def get_rate_limit_info(self) -> Dict:
234
+ """Return current GitHub API rate limit information."""
235
+ try:
236
+ rl = self._gh.get_rate_limit()
237
+ return {
238
+ "core_remaining": rl.core.remaining,
239
+ "core_limit": rl.core.limit,
240
+ "reset_at": str(rl.core.reset),
241
+ }
242
+ except Exception:
243
+ return {}
backend/llm_client.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM Client for GLM 5.1 via Z.ai API (OpenAI-compatible endpoint).
3
+ """
4
+ import time
5
+ import logging
6
+ from typing import Iterator, List, Dict, Any, Optional
7
+ import openai
8
+ from backend.config import GLM_API_KEY, GLM_BASE_URL, GLM_MODEL, LOG_LLM_CALLS
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class GLMClient:
14
+ """OpenAI-compatible wrapper for Z.ai's GLM models."""
15
+
16
+ def __init__(
17
+ self,
18
+ api_key: Optional[str] = None,
19
+ base_url: str = GLM_BASE_URL,
20
+ model: str = GLM_MODEL,
21
+ ):
22
+ self.api_key = api_key or GLM_API_KEY
23
+ self.base_url = base_url
24
+ self.model = model
25
+ self._client: Optional[openai.OpenAI] = None
26
+
27
+ def _get_client(self) -> openai.OpenAI:
28
+ if self._client is None:
29
+ if not self.api_key:
30
+ raise ValueError(
31
+ "GLM API key is not set. Please provide it in the sidebar or .env file."
32
+ )
33
+ self._client = openai.OpenAI(
34
+ api_key=self.api_key,
35
+ base_url=self.base_url,
36
+ )
37
+ return self._client
38
+
39
+ def chat(
40
+ self,
41
+ messages: List[Dict[str, str]],
42
+ temperature: float = 0.3,
43
+ max_tokens: int = 4096,
44
+ ) -> str:
45
+ """Synchronous chat completion. Returns the full response string."""
46
+ client = self._get_client()
47
+ start = time.time()
48
+
49
+ if LOG_LLM_CALLS:
50
+ logger.info(
51
+ "[GLM] chat() | model=%s | messages=%d | temp=%.1f",
52
+ self.model, len(messages), temperature,
53
+ )
54
+
55
+ response = client.chat.completions.create(
56
+ model=self.model,
57
+ messages=messages,
58
+ temperature=temperature,
59
+ max_tokens=max_tokens,
60
+ )
61
+ content = response.choices[0].message.content or ""
62
+ elapsed = time.time() - start
63
+
64
+ if LOG_LLM_CALLS:
65
+ logger.info("[GLM] completed in %.2fs | output_chars=%d", elapsed, len(content))
66
+
67
+ return content
68
+
69
+ def chat_stream(
70
+ self,
71
+ messages: List[Dict[str, str]],
72
+ temperature: float = 0.3,
73
+ max_tokens: int = 4096,
74
+ ) -> Iterator[str]:
75
+ """Streaming chat completion. Yields text chunks as they arrive."""
76
+ client = self._get_client()
77
+
78
+ if LOG_LLM_CALLS:
79
+ logger.info(
80
+ "[GLM] chat_stream() | model=%s | messages=%d",
81
+ self.model, len(messages),
82
+ )
83
+
84
+ response = client.chat.completions.create(
85
+ model=self.model,
86
+ messages=messages,
87
+ temperature=temperature,
88
+ max_tokens=max_tokens,
89
+ stream=True,
90
+ )
91
+ for chunk in response:
92
+ delta = chunk.choices[0].delta
93
+ if delta and delta.content:
94
+ yield delta.content
95
+
96
+ def update_api_key(self, api_key: str) -> None:
97
+ """Allow hot-swapping the API key (e.g. from Streamlit sidebar)."""
98
+ self.api_key = api_key
99
+ self._client = None # Force re-initialization
backend/prompts.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ All LLM prompt templates for the FixFlow agent pipeline.
3
+ Each prompt includes a system message + user message pair.
4
+ """
5
+
6
+ # ── Shared system message ─────────────────────────────────────────────────────
7
+ SYSTEM_MESSAGE = (
8
+ "You are FixFlow, an expert senior debugging engineer with 20+ years of "
9
+ "experience in software debugging, code review, and root cause analysis. "
10
+ "You systematically analyze bug reports and codebases to identify root causes "
11
+ "and generate precise, minimal fixes. You ALWAYS show your reasoning step-by-step. "
12
+ "You reference specific files, functions, and line numbers. "
13
+ "Your analysis is thorough, your explanations are clear, and your fixes are "
14
+ "safe and well-reasoned. You never make assumptions without stating them."
15
+ )
16
+
17
+
18
+ # ── Step 1: Issue Understanding ───────────────────────────────────────────────
19
+ ISSUE_ANALYSIS_PROMPT = """You have been given a GitHub issue to analyze. Your task is to extract a structured bug summary.
20
+
21
+ ## GitHub Issue Details
22
+
23
+ **Title:** {title}
24
+
25
+ **Body:**
26
+ {body}
27
+
28
+ **Labels:** {labels}
29
+
30
+ **Comments (most relevant):**
31
+ {comments}
32
+
33
+ ---
34
+
35
+ ## Your Task
36
+
37
+ Carefully read the issue and extract the following information. Be precise and include exact quotes where relevant.
38
+
39
+ Respond with a structured markdown document using EXACTLY this format:
40
+
41
+ ### πŸ› Error Message
42
+ (The exact error message, exception, or failure description. Quote directly if possible.)
43
+
44
+ ### βœ… Expected Behavior
45
+ (What the user/reporter expected to happen)
46
+
47
+ ### ❌ Actual Behavior
48
+ (What actually happened β€” the bug behavior)
49
+
50
+ ### πŸ” Reproduction Steps
51
+ (Numbered list of steps to reproduce, if provided)
52
+
53
+ ### 🎯 Affected Components
54
+ (Your best guess at which modules, files, functions, or subsystems are affected based on the issue text. List as bullet points.)
55
+
56
+ ### πŸ” Key Technical Clues
57
+ (Specific technical details: version numbers, stack traces, config values, edge cases β€” anything that will help locate the bug)
58
+
59
+ ### πŸ’‘ Hypothesis
60
+ (Your initial hypothesis about the root cause, stated clearly with reasoning)
61
+
62
+ Be thorough but concise. If information is not available, write "Not specified" rather than guessing.
63
+ """
64
+
65
+
66
+ # ── Step 2: Codebase Mapping ──────────────────────────────────────────────────
67
+ FILE_RELEVANCE_PROMPT = """You are analyzing a codebase to find files relevant to a bug report.
68
+
69
+ ## Bug Summary
70
+ {bug_summary}
71
+
72
+ ## Repository File Tree
73
+ ```
74
+ {file_tree}
75
+ ```
76
+
77
+ ## Repository: {repo_name}
78
+
79
+ ---
80
+
81
+ ## Your Task
82
+
83
+ Identify the TOP 5-10 most relevant files that are likely related to this bug.
84
+
85
+ Think step-by-step:
86
+ 1. First, consider what the error message tells you about the code path
87
+ 2. Then look at affected components mentioned in the bug
88
+ 3. Consider entry points, utilities, and configuration files
89
+ 4. Look for files matching the error traceback if one was provided
90
+
91
+ Respond with EXACTLY this format:
92
+
93
+ ### πŸ—ΊοΈ Codebase Analysis
94
+
95
+ **Repository structure overview:** (2-3 sentences about what kind of codebase this is)
96
+
97
+ ### πŸ“ Relevant Files (Ranked by Suspicion)
98
+
99
+ For each file, provide:
100
+
101
+ **[Rank]. `path/to/file.py`**
102
+ - **Relevance score:** X/10
103
+ - **Why relevant:** (specific reasoning β€” what in this file could cause the bug)
104
+ - **What to look for:** (specific functions, classes, or patterns to inspect)
105
+
106
+ ---
107
+
108
+ (Repeat for each file, ranked from most to least suspicious)
109
+
110
+ ### πŸ”Ž Files to Skip
111
+ (Brief note on any obviously irrelevant areas of the codebase)
112
+ """
113
+
114
+
115
+ # ── Step 3: Deep Code Analysis ────────────────────────────────────────────────
116
+ ROOT_CAUSE_PROMPT = """You are performing a deep code analysis to identify the root cause of a bug.
117
+
118
+ ## Bug Summary
119
+ {bug_summary}
120
+
121
+ ## Suspect Files and Content
122
+
123
+ {file_contents}
124
+
125
+ ---
126
+
127
+ ## Your Task
128
+
129
+ Trace the execution flow and identify the EXACT root cause of the bug.
130
+
131
+ **You MUST:**
132
+ - Reference specific file names, function names, and line numbers
133
+ - Show your chain-of-thought reasoning
134
+ - Trace the call chain from entry point to failure
135
+ - Identify the exact line(s) where the bug originates
136
+
137
+ Respond with EXACTLY this format:
138
+
139
+ ### πŸ”¬ Root Cause Analysis
140
+
141
+ #### Executive Summary
142
+ (1-2 sentences: what is the root cause in plain English)
143
+
144
+ #### 🧠 Chain-of-Thought Reasoning
145
+
146
+ **Step 1: Entry Point**
147
+ (Where does execution start for this bug? What triggers it?)
148
+
149
+ **Step 2: Execution Trace**
150
+ (Follow the code path step by step. For each step, cite: `filename.py:function_name()` or `filename.py:LineN`)
151
+
152
+ **Step 3: The Bug**
153
+ (The exact location and nature of the bug. Be precise: "In `file.py`, line N, function `foo()` does X when it should do Y because...")
154
+
155
+ **Step 4: Why This Causes the Reported Behavior**
156
+ (Connect the bug to the symptoms described in the issue)
157
+
158
+ #### πŸ“ Bug Location
159
+ - **File:** `path/to/file.py`
160
+ - **Function/Class:** `function_name()` / `ClassName`
161
+ - **Line(s):** ~N (approximate)
162
+ - **Type:** (e.g., off-by-one error, null check missing, race condition, type mismatch, etc.)
163
+
164
+ #### ⚠️ Contributing Factors
165
+ (Any secondary issues, missed validations, or design problems that make this worse)
166
+
167
+ #### 🎯 Confidence Level
168
+ (High/Medium/Low) β€” and why
169
+
170
+ Be thorough. Show your work. Reference specific code.
171
+ """
172
+
173
+
174
+ # ── Step 4: Fix Generation ────────────────────────────────────────────────────
175
+ FIX_GENERATION_PROMPT = """You are generating a precise, minimal fix for a confirmed bug.
176
+
177
+ ## Root Cause Analysis
178
+ {root_cause}
179
+
180
+ ## Files to Fix
181
+
182
+ {file_contents}
183
+
184
+ ---
185
+
186
+ ## Your Task
187
+
188
+ Generate corrected versions of the affected files.
189
+
190
+ **Rules for the fix:**
191
+ 1. Make the MINIMAL change needed β€” don't refactor unrelated code
192
+ 2. The fix must directly address the root cause identified above
193
+ 3. Add a comment explaining WHY the change was made (not just what)
194
+ 4. Preserve existing code style, formatting, and conventions
195
+ 5. Consider edge cases your fix must handle
196
+
197
+ For EACH file that needs changes, provide:
198
+
199
+ ---
200
+
201
+ ### Fix for `{filepath_placeholder}`
202
+
203
+ **What changed and why:**
204
+ (Brief explanation of the change)
205
+
206
+ **Fixed code:**
207
+ ```python
208
+ (FULL content of the fixed file β€” complete, not just the changed section)
209
+ ```
210
+
211
+ ---
212
+
213
+ If multiple files need changes, repeat the above section for each file.
214
+
215
+ After all fixes, add:
216
+
217
+ ### βœ… Fix Summary
218
+ - Files changed: N
219
+ - Nature of fix: (one-liner)
220
+ - Risk level: Low/Medium/High (and why)
221
+ - Edge cases handled: (bullet list)
222
+ """
223
+
224
+
225
+ # ── Step 5: Fix Explanation ───────────────────────────────────────────────────
226
+ FIX_EXPLANATION_PROMPT = """You are writing a human-readable explanation of a code fix for a pull request.
227
+
228
+ ## Original Bug
229
+ {bug_summary}
230
+
231
+ ## Root Cause
232
+ {root_cause_summary}
233
+
234
+ ## Changes Made (Unified Diff)
235
+ ```diff
236
+ {unified_diff}
237
+ ```
238
+
239
+ ---
240
+
241
+ ## Your Task
242
+
243
+ Write a clear, friendly, professional pull request description that a human reviewer can read to quickly understand and verify this fix.
244
+
245
+ Respond with EXACTLY this format:
246
+
247
+ ### πŸ“ Pull Request: Fix for [bug title]
248
+
249
+ #### πŸ› Problem
250
+ (What was the bug? 2-3 sentences, non-technical enough for a manager to understand)
251
+
252
+ #### πŸ” Root Cause
253
+ (Technical explanation of WHY this bug existed β€” 3-5 sentences)
254
+
255
+ #### πŸ”§ Solution
256
+ (What was changed and how it fixes the problem β€” reference specific lines/functions)
257
+
258
+ #### πŸ“‹ Changes
259
+ (For each changed file, one bullet: "`filename.py` β€” what changed and why")
260
+
261
+ #### πŸ§ͺ Testing Recommendations
262
+ (How a reviewer should verify this fix works β€” what to test, what edge cases to check)
263
+
264
+ #### ⚠️ Potential Side Effects
265
+ (Any risks or areas that could be affected by this change. If none, say "None identified.")
266
+
267
+ #### πŸ“š Related Issues / References
268
+ (Any related issues, docs, or context that helps understand this fix)
269
+
270
+ Write this as if you're a careful, experienced engineer who wants the reviewer to feel confident merging this PR.
271
+ """
272
+
273
+
274
+ # ── Confidence Self-Evaluation (Stretch feature) ──────────────────────────────
275
+ CONFIDENCE_EVAL_PROMPT = """Review your own analysis and rate your confidence.
276
+
277
+ ## Analysis Summary
278
+ {analysis}
279
+
280
+ ## Self-Evaluation
281
+
282
+ Rate the following on a scale of 1-10 and explain:
283
+
284
+ 1. **Root Cause Confidence** (1-10): How certain are you the identified root cause is correct?
285
+ 2. **Fix Correctness** (1-10): How confident are you the proposed fix will resolve the issue?
286
+ 3. **Fix Safety** (1-10): How safe is the fix (no regressions, no side effects)?
287
+ 4. **Completeness** (1-10): How complete is your analysis (nothing important missed)?
288
+
289
+ **Overall Score:** X/10
290
+
291
+ **Uncertainty Factors:** (What would change your diagnosis?)
292
+
293
+ **Recommended Next Steps:** (What additional verification would increase confidence?)
294
+ """
demo/example_output.md ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FixFlow Sample Output β€” FastAPI Bug Analysis
2
+
3
+ **Issue:** [FastAPI response_model doesn't strip extra fields when using Pydantic v2](https://github.com/tiangolo/fastapi/issues/10876)
4
+ **Repository:** https://github.com/tiangolo/fastapi
5
+ **Analysis Time:** 87.3s
6
+
7
+ ---
8
+
9
+ ## πŸ“‹ Step 1: Bug Summary
10
+
11
+ ### πŸ› Error Message
12
+ > "When using `response_model` in FastAPI with Pydantic v2, extra fields defined in the response model are NOT stripped from the response. This breaks the behavior expected from the `response_model_exclude_unset` pattern."
13
+
14
+ ### βœ… Expected Behavior
15
+ When a route has a `response_model` set, FastAPI should filter the response to only include fields defined in that model, stripping any additional fields from the underlying return value.
16
+
17
+ ### ❌ Actual Behavior
18
+ Extra fields from the returned object are included in the JSON response even when a `response_model` is specified. This is a regression from Pydantic v1 behavior.
19
+
20
+ ### πŸ” Reproduction Steps
21
+ 1. Install `fastapi>=0.100.0` with `pydantic>=2.0.0`
22
+ 2. Define a route: `@app.get("/users/{id}", response_model=UserOut)`
23
+ 3. Return a `UserDB` object with extra fields not in `UserOut`
24
+ 4. Observe: response includes the extra fields
25
+
26
+ ### 🎯 Affected Components
27
+ - `fastapi/routing.py` β€” route handler serialization logic
28
+ - `fastapi/_compat.py` β€” Pydantic v1/v2 compatibility layer
29
+ - `fastapi/encoders.py` β€” JSON encoding pipeline
30
+
31
+ ### πŸ” Key Technical Clues
32
+ - Introduced after Pydantic v2 migration
33
+ - `_get_value()` in `fastapi/_compat.py` changed behavior for model instances
34
+ - The `model_dump(exclude_unset=True)` call may not be filtering correctly
35
+
36
+ ### πŸ’‘ Hypothesis
37
+ The Pydantic v2 compatibility layer in `_compat.py` is not correctly calling `model_dump()` with the `include`/`exclude` parameters that respect the `response_model` field constraints. The v2 migration changed how model field serialization works.
38
+
39
+ ---
40
+
41
+ ## πŸ” Step 2: Relevant Files
42
+
43
+ ### πŸ“ Relevant Files (Ranked by Suspicion)
44
+
45
+ **1. `fastapi/_compat.py`**
46
+ - **Relevance score:** 10/10
47
+ - **Why relevant:** This is the Pydantic v1/v2 compatibility shim. All serialization changes went through here during the v2 migration.
48
+ - **What to look for:** `_get_value()`, `serialize_response()`, any calls to `model_dump()`
49
+
50
+ **2. `fastapi/routing.py`**
51
+ - **Relevance score:** 9/10
52
+ - **Why relevant:** Contains `serialize_response()` calls that apply `response_model` filtering.
53
+ - **What to look for:** `get_request_handler()`, how `response_model_include` and `response_model_exclude` are passed.
54
+
55
+ **3. `fastapi/encoders.py`**
56
+ - **Relevance score:** 7/10
57
+ - **Why relevant:** `jsonable_encoder()` handles the final conversion to JSON-safe types.
58
+ - **What to look for:** Whether `include`/`exclude` sets are respected for Pydantic v2 models.
59
+
60
+ ---
61
+
62
+ ## πŸ”¬ Step 3: Root Cause Analysis
63
+
64
+ ### Executive Summary
65
+ In `fastapi/_compat.py`, the `_get_value()` function for Pydantic v2 models calls `model_dump()` without passing the `include` parameter derived from the `response_model`'s field set, causing all fields to be serialized instead of only those defined in the response model.
66
+
67
+ ### 🧠 Chain-of-Thought Reasoning
68
+
69
+ **Step 1: Entry Point**
70
+ A GET request hits a route decorated with `@app.get("/users/{id}", response_model=UserOut)`. FastAPI's `routing.py:get_request_handler()` is invoked, which calls `serialize_response()`.
71
+
72
+ **Step 2: Execution Trace**
73
+ - `routing.py:serialize_response()` β†’ calls `_compat.py:serialize_response()` with `response_model=UserOut`
74
+ - `_compat.py:serialize_response()` calls `_get_value(response, field=response_model_field, ...)`
75
+ - **Here's the bug:** For Pydantic v2, `_get_value()` calls `value.model_dump()` but does NOT pass `include=field_set` where `field_set` contains only the fields defined in `UserOut`
76
+
77
+ **Step 3: The Bug**
78
+ In `fastapi/_compat.py`, around line 215, the v2 branch of `_get_value()`:
79
+ ```python
80
+ # BUGGY (current):
81
+ return value.model_dump(exclude_unset=exclude_unset, by_alias=by_alias)
82
+
83
+ # Should be:
84
+ return value.model_dump(
85
+ include=include,
86
+ exclude=exclude,
87
+ exclude_unset=exclude_unset,
88
+ by_alias=by_alias,
89
+ )
90
+ ```
91
+ The `include` parameter (containing the `response_model`'s allowed fields) is accepted as a function argument but silently dropped in the v2 code path.
92
+
93
+ **Step 4: Why This Causes the Reported Behavior**
94
+ Without the `include` parameter, `model_dump()` serializes ALL fields of the returned object, bypassing the `response_model` restriction. In Pydantic v1, `_get_value()` used `dict()` which was correctly called with `include` β€” this broke during the v2 migration.
95
+
96
+ ### πŸ“ Bug Location
97
+ - **File:** `fastapi/_compat.py`
98
+ - **Function/Class:** `_get_value()`
99
+ - **Line(s):** ~215
100
+ - **Type:** Missing parameter pass-through (regression from Pydantic v2 migration)
101
+
102
+ ### 🎯 Confidence Level
103
+ **High** β€” The bug is clearly a missing parameter in a well-understood code path. The fix is straightforward and surgical.
104
+
105
+ ---
106
+
107
+ ## πŸ”§ Step 4: Proposed Fix
108
+
109
+ ```diff
110
+ --- a/fastapi/_compat.py
111
+ +++ b/fastapi/_compat.py
112
+ @@ -212,7 +212,11 @@ def _get_value(
113
+ if PYDANTIC_V2:
114
+ if isinstance(value, BaseModel):
115
+ - return value.model_dump(exclude_unset=exclude_unset, by_alias=by_alias)
116
+ + # Pass include/exclude to respect response_model field constraints
117
+ + # This was missing after the Pydantic v2 migration (regression fix)
118
+ + return value.model_dump(
119
+ + include=include,
120
+ + exclude=exclude,
121
+ + exclude_unset=exclude_unset,
122
+ + by_alias=by_alias,
123
+ + )
124
+ ```
125
+
126
+ ---
127
+
128
+ ## πŸ“ Step 5: PR Description
129
+
130
+ ### πŸ“ Pull Request: Fix response_model field filtering with Pydantic v2
131
+
132
+ #### πŸ› Problem
133
+ When using FastAPI with Pydantic v2, the `response_model` parameter on route decorators no longer strips extra fields from responses. A route returning a `UserDB` object (with password, internal fields) but declaring `response_model=UserOut` would incorrectly expose the extra fields to clients.
134
+
135
+ #### πŸ” Root Cause
136
+ During the Pydantic v2 migration, `fastapi/_compat.py`'s `_get_value()` function lost the `include` parameter pass-through in the v2 code path. The `model_dump()` call was not forwarding the field inclusion constraints derived from the `response_model`.
137
+
138
+ #### πŸ”§ Solution
139
+ Added `include=include` and `exclude=exclude` parameters to the `model_dump()` call in the Pydantic v2 branch of `_get_value()`. This restores the Pydantic v1 behavior where only `response_model` fields are serialized.
140
+
141
+ #### πŸ§ͺ Testing Recommendations
142
+ 1. Create a route returning an object with extra fields, verify response only includes `response_model` fields
143
+ 2. Test `response_model_exclude_unset=True` still works correctly
144
+ 3. Run existing test suite: `pytest tests/test_response_model.py -v`
145
+
146
+ #### ⚠️ Potential Side Effects
147
+ None identified. Change only affects the Pydantic v2 code path and is additive β€” it passes parameters that were already being constructed but not forwarded.
148
+
149
+ ---
150
+
151
+ *Generated by FixFlow β€” Autonomous Bug Resolution Agent powered by GLM 5.1 (Z.ai)*
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ streamlit>=1.30.0
2
+ openai>=1.0.0
3
+ PyGithub>=2.1.0
4
+ requests>=2.31.0
5
+ python-dotenv>=1.0.0