A deployable markdown editor that connects with your self hosted files and lets you edit in a beautiful interface
1package connectors
2
3import (
4 "context"
5 "encoding/base64"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sort"
10 "strconv"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/google/go-github/v58/github"
16 "github.com/yourusername/markedit/internal/database"
17 "github.com/yourusername/markedit/internal/markdown"
18 "golang.org/x/oauth2"
19 "golang.org/x/sync/singleflight"
20)
21
22// fileTreeCache is an in-memory LRU cache for file tree data
23type fileTreeCache struct {
24 mu sync.RWMutex
25 entries map[string]*cacheEntry
26 maxSize int
27 ttl time.Duration
28 sfGroup singleflight.Group // Request deduplication
29}
30
31type cacheEntry struct {
32 data *FileNode
33 expiresAt time.Time
34 etag string // For future ETag support
35 fetchedAt time.Time
36}
37
38var globalCache = &fileTreeCache{
39 entries: make(map[string]*cacheEntry),
40 maxSize: 100, // Configurable via env
41 ttl: 5 * time.Minute,
42}
43
44func init() {
45 // Configure cache from environment
46 if ttlStr := os.Getenv("FILE_TREE_CACHE_TTL"); ttlStr != "" {
47 if ttl, err := time.ParseDuration(ttlStr); err == nil {
48 globalCache.ttl = ttl
49 }
50 }
51
52 if sizeStr := os.Getenv("FILE_TREE_CACHE_SIZE"); sizeStr != "" {
53 if size, err := strconv.Atoi(sizeStr); err == nil {
54 globalCache.maxSize = size
55 }
56 }
57}
58
59// Get retrieves from cache if not expired
60func (c *fileTreeCache) Get(key string) (*FileNode, bool) {
61 c.mu.RLock()
62 defer c.mu.RUnlock()
63
64 entry, exists := c.entries[key]
65 if !exists || time.Now().After(entry.expiresAt) {
66 return nil, false
67 }
68 return entry.data, true
69}
70
71// Set stores in cache with TTL
72func (c *fileTreeCache) Set(key string, data *FileNode) {
73 c.mu.Lock()
74 defer c.mu.Unlock()
75
76 // LRU eviction if at capacity
77 if len(c.entries) >= c.maxSize {
78 c.evictOldest()
79 }
80
81 c.entries[key] = &cacheEntry{
82 data: data,
83 expiresAt: time.Now().Add(c.ttl),
84 fetchedAt: time.Now(),
85 }
86}
87
88// Invalidate removes specific cache entry
89func (c *fileTreeCache) Invalidate(key string) {
90 c.mu.Lock()
91 defer c.mu.Unlock()
92 delete(c.entries, key)
93}
94
95// InvalidatePattern removes all matching keys (e.g., "owner/repo/*")
96func (c *fileTreeCache) InvalidatePattern(pattern string) int {
97 c.mu.Lock()
98 defer c.mu.Unlock()
99
100 count := 0
101 for key := range c.entries {
102 if matchesPattern(key, pattern) {
103 delete(c.entries, key)
104 count++
105 }
106 }
107 return count
108}
109
110// evictOldest removes the oldest entry (LRU)
111func (c *fileTreeCache) evictOldest() {
112 var oldestKey string
113 var oldestTime time.Time
114
115 for key, entry := range c.entries {
116 if oldestKey == "" || entry.fetchedAt.Before(oldestTime) {
117 oldestKey = key
118 oldestTime = entry.fetchedAt
119 }
120 }
121
122 if oldestKey != "" {
123 delete(c.entries, oldestKey)
124 }
125}
126
127// generateCacheKey creates consistent cache key
128func generateCacheKey(owner, repo, branch, path string, extensions []string) string {
129 extStr := strings.Join(extensions, ",")
130 return fmt.Sprintf("%s/%s/%s/%s/%s", owner, repo, branch, path, extStr)
131}
132
133// matchesPattern checks if key matches pattern (simple glob)
134func matchesPattern(key, pattern string) bool {
135 // Simple implementation: pattern can end with /* for prefix match
136 if strings.HasSuffix(pattern, "/*") {
137 prefix := strings.TrimSuffix(pattern, "/*")
138 return strings.HasPrefix(key, prefix)
139 }
140 return key == pattern
141}
142
143// GitHubConnector implements the Connector interface for GitHub
144type GitHubConnector struct {
145 client *github.Client
146 db *database.DB // For logging
147 userID *int // For logging
148}
149
150// NewGitHubConnector creates a new GitHub connector with an access token
151func NewGitHubConnector(accessToken string, db *database.DB, userID *int) *GitHubConnector {
152 ctx := context.Background()
153 ts := oauth2.StaticTokenSource(
154 &oauth2.Token{AccessToken: accessToken},
155 )
156 tc := oauth2.NewClient(ctx, ts)
157 client := github.NewClient(tc)
158
159 return &GitHubConnector{
160 client: client,
161 db: db,
162 userID: userID,
163 }
164}
165
166// GetType returns the connector type
167func (g *GitHubConnector) GetType() string {
168 return "github"
169}
170
171// logCacheEvent logs cache events to the database
172func (g *GitHubConnector) logCacheEvent(cacheKey, eventType string, responseTimeMs int) {
173 if g.db != nil {
174 _ = g.db.LogCacheEvent(cacheKey, "file_tree", eventType, g.userID, responseTimeMs)
175 }
176}
177
178// ListRepositories lists all repositories for the authenticated user
179func (g *GitHubConnector) ListRepositories(ctx context.Context, sortBy string) ([]Repository, error) {
180 // Map sort parameter
181 sort := "updated"
182 switch sortBy {
183 case "created":
184 sort = "created"
185 case "name":
186 sort = "full_name"
187 default:
188 sort = "updated"
189 }
190
191 opts := &github.RepositoryListOptions{
192 Sort: sort,
193 Direction: "desc",
194 Affiliation: "owner,collaborator,organization_member", // Include all repos user has access to
195 ListOptions: github.ListOptions{
196 PerPage: 100,
197 },
198 }
199
200 var allRepos []Repository
201
202 for {
203 repos, resp, err := g.client.Repositories.List(ctx, "", opts)
204 if err != nil {
205 return nil, fmt.Errorf("failed to list repositories (status: %v): %w", resp.StatusCode, err)
206 }
207
208 for _, repo := range repos {
209 allRepos = append(allRepos, Repository{
210 ID: repo.GetID(),
211 FullName: repo.GetFullName(),
212 Name: repo.GetName(),
213 Owner: repo.GetOwner().GetLogin(),
214 Private: repo.GetPrivate(),
215 DefaultBranch: repo.GetDefaultBranch(),
216 UpdatedAt: repo.GetUpdatedAt().Time,
217 })
218 }
219
220 if resp.NextPage == 0 {
221 break
222 }
223 opts.Page = resp.NextPage
224 }
225
226 return allRepos, nil
227}
228
229// ListFiles lists files in a repository path using GitHub Tree API with caching
230func (g *GitHubConnector) ListFiles(ctx context.Context, owner, repo, path, branch string, extensions []string) (*FileNode, error) {
231 // Resolve default branch first to ensure consistent cache keys
232 if branch == "" {
233 repository, _, err := g.client.Repositories.Get(ctx, owner, repo)
234 if err != nil {
235 return nil, fmt.Errorf("failed to get repository: %w", err)
236 }
237 branch = repository.GetDefaultBranch()
238 }
239
240 cacheKey := generateCacheKey(owner, repo, branch, path, extensions)
241
242 // Try cache first
243 if cached, found := globalCache.Get(cacheKey); found {
244 g.logCacheEvent(cacheKey, "hit", 0)
245 return cached, nil
246 }
247
248 // Use singleflight to deduplicate concurrent requests
249 result, err, _ := globalCache.sfGroup.Do(cacheKey, func() (interface{}, error) {
250 startTime := time.Now()
251
252 // Fetch from GitHub Tree API
253 data, err := g.fetchTreeFromGitHub(ctx, owner, repo, branch, path, extensions)
254 if err != nil {
255 return nil, err
256 }
257
258 responseTime := int(time.Since(startTime).Milliseconds())
259
260 // Store in cache
261 globalCache.Set(cacheKey, data)
262 g.logCacheEvent(cacheKey, "miss", responseTime)
263
264 return data, nil
265 })
266
267 if err != nil {
268 return nil, err
269 }
270 return result.(*FileNode), nil
271}
272
273// fetchTreeFromGitHub fetches file tree from GitHub using Tree API
274func (g *GitHubConnector) fetchTreeFromGitHub(ctx context.Context, owner, repo, branch, path string, extensions []string) (*FileNode, error) {
275 // Step 1: Get branch to get commit SHA
276 branchRef, _, err := g.client.Repositories.GetBranch(ctx, owner, repo, branch, 0)
277 if err != nil {
278 return nil, fmt.Errorf("failed to get branch: %w", err)
279 }
280 commitSHA := branchRef.GetCommit().GetSHA()
281
282 // Step 2: Fetch tree recursively (single API call)
283 tree, _, err := g.client.Git.GetTree(ctx, owner, repo, commitSHA, true)
284 if err != nil {
285 return nil, fmt.Errorf("failed to get tree: %w", err)
286 }
287
288 // Check for truncated response
289 if tree.Truncated != nil && *tree.Truncated {
290 return nil, fmt.Errorf("repository tree is truncated (>100k entries) - repository too large")
291 }
292
293 // Step 3: Filter tree entries by path and extensions
294 var filteredEntries []*github.TreeEntry
295 for _, entry := range tree.Entries {
296 // Skip if not in requested path
297 if path != "" && !strings.HasPrefix(*entry.Path, path) {
298 continue
299 }
300
301 // Filter by extensions (only for blobs/files)
302 if len(extensions) > 0 && *entry.Type == "blob" {
303 if !matchesExtensions(*entry.Path, extensions) {
304 continue
305 }
306 }
307
308 filteredEntries = append(filteredEntries, entry)
309 }
310
311 // Step 4: Build hierarchical FileNode tree from flat structure
312 root := buildFileTree(filteredEntries, path, extensions, repo)
313
314 return root, nil
315}
316
317// matchesExtensions checks if file matches extension filter
318func matchesExtensions(filename string, extensions []string) bool {
319 if len(extensions) == 0 {
320 return true
321 }
322
323 ext := strings.TrimPrefix(filepath.Ext(filename), ".")
324 for _, e := range extensions {
325 if strings.EqualFold(ext, e) {
326 return true
327 }
328 }
329 return false
330}
331
332// buildFileTree converts flat GitHub tree entries to hierarchical FileNode structure
333func buildFileTree(entries []*github.TreeEntry, rootPath string, extensions []string, repoName string) *FileNode {
334 root := &FileNode{
335 Name: repoName,
336 Path: rootPath,
337 Type: "directory",
338 IsDir: true,
339 Children: []*FileNode{},
340 }
341
342 if rootPath != "" {
343 root.Name = filepath.Base(rootPath)
344 }
345
346 // Group entries by their first path component
347 pathGroups := make(map[string][]*github.TreeEntry)
348
349 for _, entry := range entries {
350 relativePath := *entry.Path
351 if rootPath != "" {
352 relativePath = strings.TrimPrefix(relativePath, rootPath)
353 relativePath = strings.TrimPrefix(relativePath, "/")
354 }
355
356 if relativePath == "" {
357 continue
358 }
359
360 // Get first component
361 parts := strings.SplitN(relativePath, "/", 2)
362 firstComponent := parts[0]
363
364 pathGroups[firstComponent] = append(pathGroups[firstComponent], entry)
365 }
366
367 // Build tree recursively
368 for name, groupEntries := range pathGroups {
369 // Check if this is a file or directory
370 isFile := len(groupEntries) == 1 && *groupEntries[0].Type == "blob" && !strings.Contains(strings.TrimPrefix(*groupEntries[0].Path, rootPath+"/"), "/")
371
372 if isFile {
373 // It's a file
374 filePath := *groupEntries[0].Path
375 root.Children = append(root.Children, &FileNode{
376 Name: name,
377 Path: filePath,
378 Type: "file",
379 IsDir: false,
380 SHA: *groupEntries[0].SHA,
381 Size: int64(*groupEntries[0].Size),
382 })
383 } else {
384 // It's a directory - collect all direct children
385 dirNode := &FileNode{
386 Name: name,
387 Path: filepath.Join(rootPath, name),
388 Type: "directory",
389 IsDir: true,
390 Children: []*FileNode{},
391 }
392
393 // Get direct children of this directory
394 directChildren := make(map[string][]*github.TreeEntry)
395 dirPrefix := dirNode.Path + "/"
396
397 for _, entry := range groupEntries {
398 relativeToDir := strings.TrimPrefix(*entry.Path, dirPrefix)
399 parts := strings.SplitN(relativeToDir, "/", 2)
400 directChildren[parts[0]] = append(directChildren[parts[0]], entry)
401 }
402
403 // Recursively build subtree
404 subTree := buildFileTree(groupEntries, dirNode.Path, extensions, name)
405 dirNode.Children = subTree.Children
406
407 // Only add directory if it has children (matching files)
408 if len(dirNode.Children) > 0 {
409 root.Children = append(root.Children, dirNode)
410 }
411 }
412 }
413
414 // Sort: directories first, then alphabetically
415 sort.Slice(root.Children, func(i, j int) bool {
416 if root.Children[i].IsDir != root.Children[j].IsDir {
417 return root.Children[i].IsDir
418 }
419 return root.Children[i].Name < root.Children[j].Name
420 })
421
422 return root
423}
424
425// GetFileContent retrieves the content of a file
426func (g *GitHubConnector) GetFileContent(ctx context.Context, owner, repo, path, branch string) (*FileContent, error) {
427 if branch == "" {
428 // Get default branch
429 repository, _, err := g.client.Repositories.Get(ctx, owner, repo)
430 if err != nil {
431 return nil, fmt.Errorf("failed to get repository: %w", err)
432 }
433 branch = repository.GetDefaultBranch()
434 }
435
436 opts := &github.RepositoryContentGetOptions{
437 Ref: branch,
438 }
439
440 fileContent, _, _, err := g.client.Repositories.GetContents(ctx, owner, repo, path, opts)
441 if err != nil {
442 return nil, fmt.Errorf("failed to get file content: %w", err)
443 }
444
445 if fileContent == nil {
446 return nil, fmt.Errorf("file not found")
447 }
448
449 content, err := fileContent.GetContent()
450 if err != nil {
451 return nil, fmt.Errorf("failed to decode content: %w", err)
452 }
453
454 // Parse markdown with frontmatter
455 parsed, err := markdown.Parse(content)
456 if err != nil {
457 return nil, fmt.Errorf("failed to parse markdown: %w", err)
458 }
459
460 return &FileContent{
461 Content: parsed.Content,
462 Frontmatter: parsed.Frontmatter,
463 Path: path,
464 SHA: fileContent.GetSHA(),
465 Branch: branch,
466 }, nil
467}
468
469// InvalidateCacheForRepo invalidates all cache entries for a repository
470func InvalidateCacheForRepo(owner, repo string) int {
471 pattern := fmt.Sprintf("%s/%s/*", owner, repo)
472 return globalCache.InvalidatePattern(pattern)
473}
474
475// CreateFile creates a new file in the repository
476func (g *GitHubConnector) CreateFile(ctx context.Context, owner, repo, path, content, message string) error {
477 if message == "" {
478 message = fmt.Sprintf("Create %s", path)
479 }
480
481 // Get default branch
482 repository, _, err := g.client.Repositories.Get(ctx, owner, repo)
483 if err != nil {
484 return fmt.Errorf("failed to get repository: %w", err)
485 }
486 branch := repository.GetDefaultBranch()
487
488 // Encode content to base64
489 encodedContent := github.String(base64.StdEncoding.EncodeToString([]byte(content)))
490
491 // Create file options
492 opts := &github.RepositoryContentFileOptions{
493 Message: github.String(message),
494 Content: []byte(*encodedContent),
495 Branch: github.String(branch),
496 }
497
498 // Create the file
499 _, _, err = g.client.Repositories.CreateFile(ctx, owner, repo, path, opts)
500 if err != nil {
501 return fmt.Errorf("failed to create file: %w", err)
502 }
503
504 return nil
505}
506
507// RenameItem renames a file or folder by deleting the old path and creating at the new path
508func (g *GitHubConnector) RenameItem(ctx context.Context, owner, repo, oldPath, newPath, message string) error {
509 if message == "" {
510 message = fmt.Sprintf("Rename %s to %s", oldPath, newPath)
511 }
512
513 // Get default branch
514 repository, _, err := g.client.Repositories.Get(ctx, owner, repo)
515 if err != nil {
516 return fmt.Errorf("failed to get repository: %w", err)
517 }
518 branch := repository.GetDefaultBranch()
519
520 // Get the content of the old file/path
521 fileContent, _, _, err := g.client.Repositories.GetContents(ctx, owner, repo, oldPath, &github.RepositoryContentGetOptions{
522 Ref: branch,
523 })
524 if err != nil {
525 return fmt.Errorf("failed to get file content: %w", err)
526 }
527
528 // If it's a directory, we need to handle all files in it
529 if fileContent == nil {
530 // It's a directory - get all files in it
531 _, directoryContent, _, err := g.client.Repositories.GetContents(ctx, owner, repo, oldPath, &github.RepositoryContentGetOptions{
532 Ref: branch,
533 })
534 if err != nil {
535 return fmt.Errorf("failed to get directory contents: %w", err)
536 }
537
538 // Rename each file in the directory
539 for _, item := range directoryContent {
540 if item.GetType() == "file" {
541 oldFilePath := item.GetPath()
542 newFilePath := strings.Replace(oldFilePath, oldPath, newPath, 1)
543
544 // Get file content
545 content, err := item.GetContent()
546 if err != nil {
547 return fmt.Errorf("failed to get content for %s: %w", oldFilePath, err)
548 }
549
550 // Create new file
551 encodedContent := base64.StdEncoding.EncodeToString([]byte(content))
552 opts := &github.RepositoryContentFileOptions{
553 Message: github.String(message),
554 Content: []byte(encodedContent),
555 Branch: github.String(branch),
556 }
557 _, _, err = g.client.Repositories.CreateFile(ctx, owner, repo, newFilePath, opts)
558 if err != nil {
559 return fmt.Errorf("failed to create file %s: %w", newFilePath, err)
560 }
561
562 // Delete old file
563 deleteOpts := &github.RepositoryContentFileOptions{
564 Message: github.String(message),
565 SHA: github.String(item.GetSHA()),
566 Branch: github.String(branch),
567 }
568 _, _, err = g.client.Repositories.DeleteFile(ctx, owner, repo, oldFilePath, deleteOpts)
569 if err != nil {
570 return fmt.Errorf("failed to delete file %s: %w", oldFilePath, err)
571 }
572 }
573 }
574 } else {
575 // It's a single file
576 content, err := fileContent.GetContent()
577 if err != nil {
578 return fmt.Errorf("failed to get file content: %w", err)
579 }
580
581 // Create new file at new path
582 encodedContent := base64.StdEncoding.EncodeToString([]byte(content))
583 opts := &github.RepositoryContentFileOptions{
584 Message: github.String(message),
585 Content: []byte(encodedContent),
586 Branch: github.String(branch),
587 }
588 _, _, err = g.client.Repositories.CreateFile(ctx, owner, repo, newPath, opts)
589 if err != nil {
590 return fmt.Errorf("failed to create file at new path: %w", err)
591 }
592
593 // Delete old file
594 deleteOpts := &github.RepositoryContentFileOptions{
595 Message: github.String(message),
596 SHA: github.String(fileContent.GetSHA()),
597 Branch: github.String(branch),
598 }
599 _, _, err = g.client.Repositories.DeleteFile(ctx, owner, repo, oldPath, deleteOpts)
600 if err != nil {
601 return fmt.Errorf("failed to delete old file: %w", err)
602 }
603 }
604
605 return nil
606}