File size: 10,699 Bytes
7e2f74d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
import os
import stat
import shutil
import tempfile
import zipfile
import subprocess
import urllib.parse
import httpx
from typing import Dict, List, Tuple, Any

# Directories to completely ignore during scanning
IGNORED_DIRS = {
    "node_modules", "venv", ".git", "dist", "build", "__pycache__", 
    ".venv", "env", ".env", "bin", "obj", "target", "out"
}

# File extensions to ignore (binary and build artifacts)
IGNORED_EXTS = {
    ".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".zip", ".tar", 
    ".gz", ".db", ".sqlite", ".exe", ".dll", ".so", ".dylib", ".class", 
    ".pyc", ".pyd", ".woff", ".woff2", ".ttf", ".eot", ".svg", ".mp4",
    ".mp3", ".wav", ".avi", ".mov", ".zip", ".rar", ".7z", ".tar.gz",
    ".DS_Store", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock"
}

# Maximum size for a single file to be read for LLM analysis (100 KB)
MAX_FILE_SIZE_BYTES = 100 * 1024

def handle_remove_readonly(func, path, excinfo):
    """
    Error handler for shutil.rmtree on Windows to remove read-only attributes.
    """
    try:
        os.chmod(path, stat.S_IWRITE)
        func(path)
    except Exception:
        pass

def parse_github_url(url: str) -> Tuple[str, str]:
    """
    Parses owner and repository name from a GitHub URL.
    Supports formats like:
      - https://github.com/owner/repo
      - https://github.com/owner/repo.git
      - git@github.com:owner/repo.git
    """
    url = url.strip()
    if url.endswith(".git"):
        url = url[:-4]
    
    if url.startswith("git@github.com:"):
        path = url.split("git@github.com:")[1]
    elif "github.com/" in url:
        path = url.split("github.com/")[1]
    else:
        # Fallback if it is just "owner/repo"
        path = url

    parts = [p for p in path.split("/") if p]
    if len(parts) >= 2:
        return parts[0], parts[1]
    raise ValueError("Invalid GitHub URL format. Expected 'https://github.com/owner/repo'")

async def check_repository_privacy(url: str, token: str = None) -> Dict[str, Any]:
    """
    Checks if a GitHub repository is public or private.
    Returns a dict with 'status' (public/private/invalid), 'message', and 'owner_repo'.
    """
    try:
        owner, repo = parse_github_url(url)
        owner_repo = f"{owner}/{repo}"
    except Exception as e:
        return {
            "status": "invalid",
            "message": f"Could not parse GitHub URL: {str(e)}",
            "owner_repo": None
        }

    api_url = f"https://api.github.com/repos/{owner}/{repo}"
    headers = {
        "Accept": "application/vnd.github+json",
        "User-Agent": "Repository-Intelligence-App"
    }
    
    # Check without token first
    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(api_url, headers=headers)
            if response.status_code == 200:
                return {
                    "status": "public",
                    "message": "Repository is public.",
                    "owner_repo": owner_repo
                }
            elif response.status_code == 404:
                # If a token is provided, verify access with the token
                if token:
                    headers["Authorization"] = f"token {token}"
                    token_response = await client.get(api_url, headers=headers)
                    if token_response.status_code == 200:
                        return {
                            "status": "private",
                            "message": "Private repository access validated successfully.",
                            "owner_repo": owner_repo
                        }
                    else:
                        return {
                            "status": "private_denied",
                            "message": "Access denied. Please check your GitHub Personal Access Token.",
                            "owner_repo": owner_repo
                        }
                return {
                    "status": "private_requires_auth",
                    "message": "Repository is private or does not exist. A GitHub Personal Access Token is required.",
                    "owner_repo": owner_repo
                }
            else:
                return {
                    "status": "error",
                    "message": f"GitHub API returned HTTP {response.status_code}",
                    "owner_repo": owner_repo
                }
        except Exception as e:
            return {
                "status": "error",
                "message": f"Failed to connect to GitHub: {str(e)}",
                "owner_repo": owner_repo
            }

def sanitize_git_error(error_msg: str, token: str) -> str:
    """
    Removes Personal Access Tokens from git output logs and errors.
    """
    if not token:
        return error_msg
    return error_msg.replace(token, "[REDACTED]")

def clone_repository(url: str, dest_dir: str, token: str = None) -> None:
    """
    Clones a repository into a destination directory. Sanitizes token output.
    """
    try:
        owner, repo = parse_github_url(url)
    except Exception as e:
        raise Exception(f"Failed to parse repository URL: {str(e)}")

    if token:
        # Build authenticated URL
        # Format: https://x-access-token:<token>@github.com/owner/repo.git
        encoded_token = urllib.parse.quote(token)
        clone_url = f"https://x-access-token:{encoded_token}@github.com/{owner}/{repo}.git"
    else:
        clone_url = f"https://github.com/{owner}/{repo}.git"

    # git clone requires the destination to be empty. Since tempfile.mkdtemp
    # creates the directory, we clone into it using '.' which works when empty.
    cmd = ["git", "clone", "--depth", "1", clone_url, "."]
    
    try:
        # Run clone command inside dest_dir. Divert stderr to capture execution errors.
        result = subprocess.run(cmd, capture_output=True, text=True, check=True, cwd=dest_dir)
    except subprocess.CalledProcessError as e:
        stderr_sanitized = sanitize_git_error(e.stderr, token)
        raise Exception(f"Git clone failed: {stderr_sanitized}")
    except Exception as e:
        raise Exception(f"Git execution error: {str(e)}")

def extract_zip(zip_path: str, dest_dir: str) -> None:
    """
    Extracts an uploaded zip file into a target directory.
    Includes security protection against path traversal.
    """
    target_dir = os.path.abspath(dest_dir)
    
    with zipfile.ZipFile(zip_path, 'r') as zip_ref:
        for member in zip_ref.infolist():
            # Resolve target path and verify it remains within target directory bounds
            target_path = os.path.abspath(os.path.join(target_dir, member.filename))
            if not target_path.startswith(target_dir + os.sep) and target_path != target_dir:
                raise Exception(f"Security Warning: Path traversal attempt detected in zip file: {member.filename}")
        
        # Safe to extract
        zip_ref.extractall(target_dir)

def is_text_file(file_path: str) -> bool:
    """
    Heuristically checks if a file is a text file by scanning its initial bytes.
    Also respects files that are purely empty as text files.
    """
    # Check extension first
    _, ext = os.path.splitext(file_path)
    if ext.lower() in IGNORED_EXTS:
        return False
        
    try:
        with open(file_path, 'rb') as f:
            chunk = f.read(1024)
            if b'\x00' in chunk:  # Binary files typically contain null bytes
                return False
            # Check if it can be decoded as utf-8 or ascii
            try:
                chunk.decode('utf-8')
            except UnicodeDecodeError:
                try:
                    chunk.decode('latin-1')
                except UnicodeDecodeError:
                    return False
        return True
    except Exception:
        return False

def scan_directory(dir_path: str) -> Dict[str, Any]:
    """
    Recursively scans the directory and returns:
    1. A nested file tree structure for visualization.
    2. A flat list of code files with their relative path and partial text contents (if key).
    """
    file_tree = {}
    flat_files = []
    
    # Resolve the absolute path
    abs_dir_path = os.path.abspath(dir_path)

    # Let's check if the unzipped repository structure has a single root folder wrapping the project
    # (common in GitHub source code ZIPs like repo-name-main/)
    scan_root = abs_dir_path
    subdirs = os.listdir(abs_dir_path)
    # If the folder contains only a single directory and no other files, we dive in
    if len(subdirs) == 1:
        single_path = os.path.join(abs_dir_path, subdirs[0])
        if os.path.isdir(single_path) and subdirs[0] not in IGNORED_DIRS:
            scan_root = single_path

    # Helper to recursively build tree
    def build_tree(current_dir: str, tree_node: Dict[str, Any]) -> None:
        try:
            entries = os.listdir(current_dir)
        except Exception:
            return

        for entry in entries:
            if entry in IGNORED_DIRS:
                continue

            full_path = os.path.join(current_dir, entry)
            rel_path = os.path.relpath(full_path, scan_root).replace("\\", "/")

            if os.path.isdir(full_path):
                tree_node[entry] = {
                    "type": "directory",
                    "path": rel_path,
                    "children": {}
                }
                build_tree(full_path, tree_node[entry]["children"])
                # If directory has no children, we still keep it
            else:
                _, ext = os.path.splitext(entry)
                if ext.lower() in IGNORED_EXTS:
                    continue
                
                size = os.path.getsize(full_path)
                tree_node[entry] = {
                    "type": "file",
                    "path": rel_path,
                    "size": size
                }
                
                # Check if it is a text file and size is within limits
                if size <= MAX_FILE_SIZE_BYTES and is_text_file(full_path):
                    try:
                        with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
                            content = f.read()
                        flat_files.append({
                            "path": rel_path,
                            "size": size,
                            "content": content
                        })
                    except Exception:
                        pass

    root_tree = {}
    build_tree(scan_root, root_tree)
    
    return {
        "tree": root_tree,
        "files": flat_files,
        "scan_root": scan_root
    }