forked from wham/github-brain
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.go
More file actions
5910 lines (5057 loc) · 188 KB
/
main.go
File metadata and controls
5910 lines (5057 loc) · 188 KB
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
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package main
import (
"context"
"database/sql"
_ "embed"
"encoding/json"
"fmt"
"html"
"html/template"
"io"
"log/slog"
"math/rand"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/joho/godotenv"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
_ "github.com/mattn/go-sqlite3"
"github.com/shurcooL/githubv4"
"golang.org/x/oauth2"
"golang.org/x/term"
)
// Embedded static assets
//go:embed index.html
var indexHTML string
//go:embed htmx.min.js
var htmxJS []byte
// Database schema version GUID - change this on any schema modification
const SCHEMA_GUID = "b8f3c2a1-9e7d-4f6b-8c5a-3d2e1f0a9b8c"
// Global variables for rate limit handling and status tracking
var (
rateLimitMutex sync.Mutex
rateLimitHit bool
rateLimitResetTime time.Time
secondaryRateLimitHit bool
secondaryResetTime time.Time
backoffDuration time.Duration = 5 * time.Second // Increased from 1s to 5s for better handling
maxBackoffDuration time.Duration = 10 * time.Minute // Keep at 10 minutes max
// Rate limit information from headers
currentRateLimit RateLimitInfo = RateLimitInfo{Limit: -1, Remaining: -1, Used: -1} // Initialize with -1 for unknown
rateLimitInfoMutex sync.RWMutex
// Status code counters
statusCounters StatusCounters
statusMutex sync.Mutex
// Global console reference for logging
globalLogger *slog.Logger
)
// ConsoleHandler is a custom slog handler that writes to the Console
type ConsoleHandler struct {
console *Console
attrs []slog.Attr
groups []string
mutex sync.Mutex
}
// NewConsoleHandler creates a new ConsoleHandler
func NewConsoleHandler(console *Console) *ConsoleHandler {
return &ConsoleHandler{
console: console,
}
}
// Enabled returns true if the handler handles records at the given level
func (h *ConsoleHandler) Enabled(ctx context.Context, level slog.Level) bool {
return true // Handle all levels
}
// Handle processes a log record
func (h *ConsoleHandler) Handle(ctx context.Context, record slog.Record) error {
h.mutex.Lock()
defer h.mutex.Unlock()
if h.console == nil {
return nil // No console available
}
// Build the message with attributes
message := record.Message
record.Attrs(func(a slog.Attr) bool {
if a.Key != "" && a.Value.String() != "" {
message += fmt.Sprintf(" %s=%s", a.Key, a.Value.String())
}
return true
})
// Add any handler-level attributes
for _, attr := range h.attrs {
if attr.Key != "" && attr.Value.String() != "" {
message += fmt.Sprintf(" %s=%s", attr.Key, attr.Value.String())
}
}
// Log to console (which handles formatting with timestamp)
h.console.Log("%s", message)
return nil
}
// WithAttrs returns a new handler with the given attributes added
func (h *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs))
copy(newAttrs, h.attrs)
copy(newAttrs[len(h.attrs):], attrs)
return &ConsoleHandler{
console: h.console,
attrs: newAttrs,
groups: h.groups,
}
}
// WithGroup returns a new handler with the given group name added
func (h *ConsoleHandler) WithGroup(name string) slog.Handler {
newGroups := make([]string, len(h.groups)+1)
copy(newGroups, h.groups)
newGroups[len(h.groups)] = name
return &ConsoleHandler{
console: h.console,
attrs: h.attrs,
groups: newGroups,
}
}
// SetupGlobalLogger sets up the global logger with console handler
func SetupGlobalLogger(console *Console) {
handler := NewConsoleHandler(console)
globalLogger = slog.New(handler)
slog.SetDefault(globalLogger)
}
// RateLimitInfo holds rate limit information from GitHub API headers
type RateLimitInfo struct {
Limit int // x-ratelimit-limit (default -1 for unknown)
Remaining int // x-ratelimit-remaining (default -1 for unknown)
Used int // x-ratelimit-used (default -1 for unknown)
Reset time.Time // x-ratelimit-reset (Unix timestamp)
}
// StatusCounters tracks HTTP response status codes
type StatusCounters struct {
Success2XX int // 2XX status codes
Error4XX int // 4XX status codes
Error5XX int // 5XX status codes
}
// addRequestDelay adds a delay between API requests to help avoid secondary rate limits
func addRequestDelay() {
// Check if we're in a secondary rate limit state - if so, use longer delays
rateLimitMutex.Lock()
inSecondaryLimit := secondaryRateLimitHit
inPrimaryLimit := rateLimitHit
rateLimitMutex.Unlock()
var delay time.Duration
if inSecondaryLimit {
// Much longer delay when we're recovering from secondary rate limits
delay = time.Duration(7000+rand.Intn(3000)) * time.Millisecond // 7-10 seconds
} else if inPrimaryLimit {
// Longer delay when recovering from primary rate limits
delay = time.Duration(5000+rand.Intn(3000)) * time.Millisecond // 5-8 seconds
} else {
// Check current rate limit status for adaptive delays based on points utilization
rateLimitInfoMutex.RLock()
remaining := currentRateLimit.Remaining
limit := currentRateLimit.Limit
rateLimitInfoMutex.RUnlock()
if remaining > 0 && limit > 0 {
// Calculate points utilization (GitHub's rate limiting is points-based)
pointsUsed := float64(limit-remaining) / float64(limit)
if pointsUsed > 0.9 { // Above 90% points used
// Very conservative delay when close to rate limit
delay = time.Duration(3000+rand.Intn(2000)) * time.Millisecond // 3-5 seconds
} else if pointsUsed > 0.7 { // Above 70% points used
// More conservative delay
delay = time.Duration(2000+rand.Intn(1000)) * time.Millisecond // 2-3 seconds
} else if pointsUsed > 0.5 { // Above 50% points used
// Moderate delay
delay = time.Duration(1000+rand.Intn(1000)) * time.Millisecond // 1-2 seconds
} else {
// Normal delay (GitHub recommends 1+ second between mutations)
delay = time.Duration(1000+rand.Intn(500)) * time.Millisecond // 1-1.5 seconds
}
} else {
// Default delay when rate limit info is unknown - be conservative
delay = time.Duration(1500+rand.Intn(1000)) * time.Millisecond // 1.5-2.5 seconds
}
}
time.Sleep(delay)
}
// updateRateLimitInfo updates the global rate limit information from HTTP headers
func updateRateLimitInfo(headers http.Header) {
rateLimitInfoMutex.Lock()
defer rateLimitInfoMutex.Unlock()
if limit := headers.Get("x-ratelimit-limit"); limit != "" {
if val, err := strconv.Atoi(limit); err == nil {
currentRateLimit.Limit = val
}
}
if remaining := headers.Get("x-ratelimit-remaining"); remaining != "" {
if val, err := strconv.Atoi(remaining); err == nil {
currentRateLimit.Remaining = val
}
}
if used := headers.Get("x-ratelimit-used"); used != "" {
if val, err := strconv.Atoi(used); err == nil {
currentRateLimit.Used = val
}
}
if reset := headers.Get("x-ratelimit-reset"); reset != "" {
if val, err := strconv.ParseInt(reset, 10, 64); err == nil {
currentRateLimit.Reset = time.Unix(val, 0)
}
}
}
// getRateLimitInfo returns a copy of the current rate limit information
func getRateLimitInfo() RateLimitInfo {
rateLimitInfoMutex.RLock()
defer rateLimitInfoMutex.RUnlock()
return currentRateLimit
}
// updateStatusCounter increments the appropriate status code counter
func updateStatusCounter(statusCode int) {
statusMutex.Lock()
defer statusMutex.Unlock()
switch {
case statusCode >= 200 && statusCode < 300:
statusCounters.Success2XX++
case statusCode >= 400 && statusCode < 500:
statusCounters.Error4XX++
case statusCode >= 500:
statusCounters.Error5XX++
}
}
// getStatusCounters returns a copy of the current status counters
func getStatusCounters() StatusCounters {
statusMutex.Lock()
defer statusMutex.Unlock()
return statusCounters
}
// CustomTransport wraps the default HTTP transport to capture response headers and status codes
type CustomTransport struct {
wrapped http.RoundTripper
}
func (ct *CustomTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := ct.wrapped.RoundTrip(req)
if resp != nil {
// Update status counters
updateStatusCounter(resp.StatusCode)
// Update rate limit info from headers
updateRateLimitInfo(resp.Header)
// Handle 429 status code (rate limit) with Retry-After header
if resp.StatusCode == 429 {
retryAfter := resp.Header.Get("Retry-After")
rateLimitMutex.Lock()
defer rateLimitMutex.Unlock()
// Check if this is secondary rate limit (abuse detection)
// GitHub secondary rate limits typically have abuse detection messages
if retryAfter != "" {
if retryAfterInt, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
waitDuration := time.Duration(retryAfterInt) * time.Second
// Cap the wait time to prevent excessive waiting
maxWaitTime := 10 * time.Minute
if waitDuration > maxWaitTime {
slog.Warn("Capping excessive Retry-After duration", "from", waitDuration, "to", maxWaitTime)
waitDuration = maxWaitTime
}
// Set secondary rate limit if this appears to be abuse detection
// (typically longer wait times indicate secondary rate limits)
if waitDuration > 60*time.Second {
secondaryRateLimitHit = true
secondaryResetTime = time.Now().Add(waitDuration)
slog.Info("GitHub API secondary rate limit (429) detected", "retry_after", waitDuration.String(), "until", secondaryResetTime.Format(time.RFC3339))
} else {
// Shorter wait times are likely primary rate limits
rateLimitHit = true
rateLimitResetTime = time.Now().Add(waitDuration)
slog.Info("GitHub API primary rate limit (429) detected", "retry_after", waitDuration.String(), "until", rateLimitResetTime.Format(time.RFC3339))
}
}
} else {
// No Retry-After header, assume secondary rate limit with default backoff
secondaryRateLimitHit = true
waitDuration := backoffDuration
// Increase backoff for next time (exponential backoff)
backoffDuration = backoffDuration * 2
if backoffDuration > maxBackoffDuration {
backoffDuration = maxBackoffDuration
}
secondaryResetTime = time.Now().Add(waitDuration)
slog.Info("GitHub API secondary rate limit (429) detected without Retry-After", "backoff", waitDuration.String(), "until", secondaryResetTime.Format(time.RFC3339))
}
}
}
return resp, err
}
func init() {
// No need to seed the random number generator in Go 1.20+
// It's automatically seeded with a random value
}
// Config holds all application configuration
type Config struct {
GithubToken string
Organization string
HomeDir string // GitHub Brain home directory (default: ~/.github-brain)
DBDir string // SQLite database path, constructed as <HomeDir>/db
Items []string // Items to pull (repositories, discussions, issues, pull-requests)
Force bool // Remove all data before pulling
ExcludedRepositories []string // Comma-separated list of repositories to exclude from the pull of discussions, issues, and pull-requests
}
// LoadConfig creates a config from command line arguments and environment variables
// Command line arguments take precedence over environment variables
func LoadConfig(args []string) *Config {
// Get default home directory with expansion
defaultHomeDir := os.Getenv("HOME")
if defaultHomeDir == "" {
defaultHomeDir = "."
}
defaultHomeDir = defaultHomeDir + "/.github-brain"
config := &Config{
HomeDir: defaultHomeDir,
}
// Load from environment variables first
config.GithubToken = os.Getenv("GITHUB_TOKEN")
config.Organization = os.Getenv("ORGANIZATION")
if excludedRepos := os.Getenv("EXCLUDED_REPOSITORIES"); excludedRepos != "" {
config.ExcludedRepositories = splitItems(excludedRepos)
}
// Command line args override environment variables
for i := 0; i < len(args); i++ {
switch args[i] {
case "-t":
if i+1 < len(args) {
config.GithubToken = args[i+1]
i++
}
case "-o":
if i+1 < len(args) {
config.Organization = args[i+1]
i++
}
case "-m":
if i+1 < len(args) {
homeDir := args[i+1]
// Expand ~ to home directory
if strings.HasPrefix(homeDir, "~/") {
userHomeDir := os.Getenv("HOME")
if userHomeDir != "" {
homeDir = userHomeDir + homeDir[1:]
}
}
config.HomeDir = homeDir
i++
}
case "-i":
if i+1 < len(args) {
config.Items = splitItems(args[i+1])
i++
}
case "-e":
if i+1 < len(args) {
config.ExcludedRepositories = splitItems(args[i+1])
i++
}
case "-f":
config.Force = true
}
}
// Construct DBDir from HomeDir after all arguments are parsed
config.DBDir = config.HomeDir + "/db"
return config
}
// isRepositoryExcluded checks if a repository is in the excluded list
func isRepositoryExcluded(repoName string, excludedRepos []string) bool {
for _, excluded := range excludedRepos {
if strings.TrimSpace(excluded) == strings.TrimSpace(repoName) {
return true
}
}
return false
}
// splitItems splits a comma-separated items list
func splitItems(items string) []string {
if items == "" {
return nil
}
itemNames := strings.Split(items, ",")
for i, name := range itemNames {
itemNames[i] = strings.TrimSpace(name)
}
return itemNames
}
// LogEntry represents a timestamped log message
type LogEntry struct {
timestamp time.Time
message string
}
// Console represents a synchronized console output manager
type Console struct {
mutex sync.Mutex
updateChan chan struct{}
stopChan chan struct{}
throttleMs int
progressRef *Progress // Reference to the Progress instance
logEntries []LogEntry // Last 5 log messages
maxLogEntries int // Maximum number of log entries to keep
}
// NewConsole creates a new console manager with throttled updates
func NewConsole(throttleMs int) *Console {
if throttleMs <= 0 {
throttleMs = 150 // Reduced default throttle for faster refresh rate
}
console := &Console{
updateChan: make(chan struct{}, 3), // Reduced buffer size to prevent excessive queuing
stopChan: make(chan struct{}),
throttleMs: throttleMs,
maxLogEntries: 5, // Keep last 5 log messages
}
return console
}
// SetProgressRef sets the reference to the Progress instance
func (c *Console) SetProgressRef(p *Progress) {
c.progressRef = p
}
// Start begins the console update loop
func (c *Console) Start() {
go func() {
throttle := time.NewTicker(time.Duration(c.throttleMs) * time.Millisecond)
defer throttle.Stop()
var pendingUpdate bool
var lastRender time.Time
for {
select {
case <-c.stopChan:
return
case <-c.updateChan:
// Only mark as pending if enough time has passed since last render
if time.Since(lastRender) > time.Duration(c.throttleMs/2)*time.Millisecond {
pendingUpdate = true
}
case <-throttle.C:
if pendingUpdate && c.progressRef != nil {
pendingUpdate = false
lastRender = time.Now()
// Actual rendering happens here
c.progressRef.renderStatus()
}
}
}
}()
}
// RequestUpdate signals that the console should be updated
func (c *Console) RequestUpdate() {
select {
case c.updateChan <- struct{}{}:
// Update requested
default:
// Channel buffer is full, update will happen soon anyway
}
}
// Stop stops the console manager
func (c *Console) Stop() {
close(c.stopChan)
}
// Log adds a log message with timestamp to the console with batching
func (c *Console) Log(format string, args ...interface{}) {
c.mutex.Lock()
defer c.mutex.Unlock()
message := fmt.Sprintf(format, args...)
entry := LogEntry{
timestamp: time.Now(),
message: message,
}
// Add new entry
c.logEntries = append(c.logEntries, entry)
// Keep only the last maxLogEntries
if len(c.logEntries) > c.maxLogEntries {
c.logEntries = c.logEntries[1:]
}
// Only request console update for important messages or errors to reduce spam
isImportant := strings.Contains(message, "Error:") ||
strings.Contains(message, "Failed") ||
strings.Contains(message, "rate limit") ||
strings.Contains(message, "429")
if isImportant {
c.RequestUpdate()
}
// For non-important messages, let the regular ticker handle updates
}
// GetLogEntries returns a copy of the current log entries
func (c *Console) GetLogEntries() []LogEntry {
c.mutex.Lock()
defer c.mutex.Unlock()
// Ensure we never return more than maxLogEntries, even if the slice temporarily exceeds it
maxEntries := c.maxLogEntries
if len(c.logEntries) > maxEntries {
// Trim to maxEntries and update the slice
c.logEntries = c.logEntries[len(c.logEntries)-maxEntries:]
}
// Return a copy to avoid race conditions
entries := make([]LogEntry, len(c.logEntries))
copy(entries, c.logEntries)
return entries
}
// ClearScreen clears the console screen efficiently
func (c *Console) ClearScreen() {
c.mutex.Lock()
defer c.mutex.Unlock()
// Use minimal escape sequence - just return to beginning of line
fmt.Print("\r")
}
// getTerminalSize detects terminal size for bounds checking
func getTerminalSize() (width, height int) {
// Try to get terminal size
if term.IsTerminal(int(os.Stdout.Fd())) {
width, height, err := term.GetSize(int(os.Stdout.Fd()))
if err == nil && width > 0 && height > 0 {
return width, height
}
}
// Default fallback values if detection fails
return 80, 24
}
// formatNumber formats numbers with comma separators for better readability
func formatNumber(n int) string {
if n < 1000 {
return strconv.Itoa(n)
}
str := strconv.Itoa(n)
var result strings.Builder
for i, digit := range str {
if i > 0 && (len(str)-i)%3 == 0 {
result.WriteString(",")
}
result.WriteRune(digit)
}
return result.String()
}
// formatTimeRemaining formats duration in human-friendly format
func formatTimeRemaining(resetTime time.Time) string {
if resetTime.IsZero() {
return "?"
}
remaining := time.Until(resetTime)
if remaining <= 0 {
return "now"
}
hours := int(remaining.Hours())
minutes := int(remaining.Minutes()) % 60
seconds := int(remaining.Seconds()) % 60
if hours > 0 {
if minutes > 0 {
return fmt.Sprintf("%dh %dm", hours, minutes)
}
return fmt.Sprintf("%dh", hours)
} else if minutes > 0 {
return fmt.Sprintf("%dm", minutes)
} else {
return fmt.Sprintf("%ds", seconds)
}
}
// max returns the maximum of two integers
func max(a, b int) int {
if a > b {
return a
}
return b
}
// Progress represents a progress indicator
type Progress struct {
message string
spinChars []string // Modern emoji spinners
current int
stopChan chan struct{}
ticker *time.Ticker
requestRatePerSec int // Requests per second
rateUpdateChan chan int
minInterval time.Duration // Minimum interval for the spinner (fastest speed)
maxInterval time.Duration // Maximum interval for the spinner (slowest speed)
items map[string]itemStatus // Status of each item (repositories, discussions, issues, pull-requests)
currentItem string // Currently processing item
console *Console // Console manager for synchronized output
mutex sync.Mutex // Mutex to protect updates to Progress fields
rendering bool // Flag to prevent overlapping renders
lastRenderTime time.Time // For debounced updates
terminalWidth int // Terminal width for bounds checking
terminalHeight int // Terminal height for bounds checking
savedCursorPos bool // Whether cursor position has been saved
preserveOnExit bool // Whether to preserve display on signal exit
signalChan chan os.Signal // Channel for signal handling
signalDone chan struct{} // Channel to signal the signal handler to exit
boxWidth int // Calculated box width for current terminal
startTime time.Time // Track when the process started
}
// itemStatus represents the status of an item being pulled
type itemStatus struct {
enabled bool // Whether the item is enabled for pulling
completed bool // Whether the item has been completed
failed bool // Whether the item has failed
errorMessage string // Error message if failed
count int // Count of items processed
}
// NewProgress creates a new progress indicator
func NewProgress(message string) *Progress {
console := NewConsole(200) // Set to 200ms minimum interval for debounced updates
// Detect terminal size
width, height := getTerminalSize()
progress := &Progress{
message: message,
spinChars: []string{"🔄", "🔃", "⚡", "🔁"}, // Modern emoji spinners
stopChan: make(chan struct{}),
ticker: time.NewTicker(750 * time.Millisecond), // Faster refresh rate for better responsiveness
requestRatePerSec: 0,
rateUpdateChan: make(chan int, 5), // Reduced buffer size
minInterval: 200 * time.Millisecond, // Slower minimum interval
maxInterval: 750 * time.Millisecond, // Faster maximum interval for better responsiveness
items: make(map[string]itemStatus),
currentItem: "",
console: console,
lastRenderTime: time.Time{}, // Initialize to zero time
terminalWidth: width,
terminalHeight: height,
savedCursorPos: false,
preserveOnExit: true, // Default to preserving display on exit
signalChan: make(chan os.Signal, 1), // Channel for signal handling
signalDone: make(chan struct{}), // Channel to signal the signal handler to exit
boxWidth: max(64, width-8), // Minimum 64 chars, scale with terminal, more margin for safety
startTime: time.Now(), // Track start time
}
// Set the reference to the Progress in the Console
console.SetProgressRef(progress)
console.Start()
// Set up signal handling for graceful exit with preserved display and window resize
signal.Notify(progress.signalChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGWINCH)
go progress.handleSignals()
// Save current cursor position and hide cursor, then reserve space for display
fmt.Print("\033[s\033[?25l") // Save cursor position and hide cursor
// Reserve 17 lines for our modern boxed display (with 5 log lines)
for i := 0; i < 17; i++ {
fmt.Println()
}
// Move back to the start of our reserved area
fmt.Print("\033[17A")
progress.savedCursorPos = true
return progress
}
// handleSignals handles OS signals for graceful shutdown with preserved display and window resize
func (p *Progress) handleSignals() {
for {
select {
case sig := <-p.signalChan:
switch sig {
case syscall.SIGINT, syscall.SIGTERM:
// Preserve display on signal exit
p.preserveOnExit = true
p.StopWithPreserve()
os.Exit(0)
case syscall.SIGWINCH:
// Handle terminal resize
p.handleTerminalResize()
}
case <-p.signalDone:
return
}
}
}
// handleTerminalResize handles terminal window resize events
func (p *Progress) handleTerminalResize() {
p.mutex.Lock()
defer p.mutex.Unlock()
// Get new terminal size
width, height := getTerminalSize()
// Update terminal dimensions
p.terminalWidth = width
p.terminalHeight = height
// Update box width with same calculation as initialization
p.boxWidth = max(64, width-8)
// Trigger immediate re-render
p.console.RequestUpdate()
}
// InitItems initializes the items to be displayed
func (p *Progress) InitItems(config *Config) {
p.mutex.Lock()
// Check if specific items are requested
enabledItems := make(map[string]bool)
// If no specific items provided, enable all
if len(config.Items) == 0 {
enabledItems["repositories"] = true
enabledItems["discussions"] = true
enabledItems["issues"] = true
enabledItems["pull-requests"] = true
} else {
// Otherwise, only enable the requested items
for _, item := range config.Items {
enabledItems[item] = true
}
}
// Initialize item statuses
p.items["repositories"] = itemStatus{enabled: enabledItems["repositories"], completed: false, failed: false, errorMessage: "", count: 0}
p.items["discussions"] = itemStatus{enabled: enabledItems["discussions"], completed: false, failed: false, errorMessage: "", count: 0}
p.items["issues"] = itemStatus{enabled: enabledItems["issues"], completed: false, failed: false, errorMessage: "", count: 0}
p.items["pull-requests"] = itemStatus{enabled: enabledItems["pull-requests"], completed: false, failed: false, errorMessage: "", count: 0}
p.mutex.Unlock()
// Display initial status immediately to establish stable layout
p.renderStatus()
// Then request throttled updates for future changes
p.console.RequestUpdate()
}
// SetCurrentItem sets the currently processing item
func (p *Progress) SetCurrentItem(item string) {
p.mutex.Lock()
p.currentItem = item
p.mutex.Unlock()
p.console.RequestUpdate()
}
// MarkItemCompleted marks an item as completed with final count
func (p *Progress) MarkItemCompleted(item string, count int) {
p.mutex.Lock()
if status, exists := p.items[item]; exists {
status.completed = true
status.failed = false
status.errorMessage = ""
status.count = count
p.items[item] = status
}
// Only clear currentItem if this item was the current one
if p.currentItem == item {
p.currentItem = ""
}
p.mutex.Unlock()
p.console.RequestUpdate()
}
// UpdateItemCount updates the count for the current item with reduced update frequency
func (p *Progress) UpdateItemCount(item string, count int) {
p.mutex.Lock()
if status, exists := p.items[item]; exists {
// Only update if count has changed significantly to reduce console spam
if count != status.count && (count%10 == 0 || count < 10 || count-status.count > 5) {
status.count = count
p.items[item] = status
p.mutex.Unlock()
// Use throttled console update instead of immediate render to prevent duplicated output
p.console.RequestUpdate()
} else {
// Just update the count without triggering render
status.count = count
p.items[item] = status
p.mutex.Unlock()
}
} else {
p.mutex.Unlock()
}
}
// MarkItemFailed marks an item as failed with an error message
func (p *Progress) MarkItemFailed(item string, errorMessage string) {
p.mutex.Lock()
if status, exists := p.items[item]; exists {
status.completed = false
status.failed = true
status.errorMessage = errorMessage
p.items[item] = status
}
p.currentItem = ""
p.mutex.Unlock()
p.console.RequestUpdate()
}
// HasAnyFailed returns true if any item has failed
func (p *Progress) HasAnyFailed() bool {
p.mutex.Lock()
defer p.mutex.Unlock()
for _, status := range p.items {
if status.failed {
return true
}
}
return false
}
// capitalize first letter of a string
func capitalize(s string) string {
if s == "" {
return ""
}
// Handle special cases for display names
switch s {
case "pull-requests":
return "Pull Requests"
default:
return strings.ToUpper(s[:1]) + s[1:]
}
}
// renderStatus renders the modern boxed console interface
func (p *Progress) renderStatus() {
// Implement debounced updates with minimum 200ms interval
now := time.Now()
if !p.lastRenderTime.IsZero() && now.Sub(p.lastRenderTime) < 200*time.Millisecond {
return // Skip update, too soon since last render
}
// Ensure thread-safe access to Progress fields and prevent overlapping renders
p.mutex.Lock()
if p.rendering {
p.mutex.Unlock()
return // Skip if already rendering
}
p.rendering = true
defer func() {
p.rendering = false
p.lastRenderTime = now
p.mutex.Unlock()
}()
// Initialize box width if not set
if p.boxWidth == 0 {
width, height := getTerminalSize()
p.terminalWidth = width
p.terminalHeight = height
p.boxWidth = max(64, width-8) // Minimum 64 chars, scale with terminal, more margin for safety
}
// Ensure we have enough terminal height for our display
if p.terminalHeight < 17 {
return // Terminal too small, skip rendering
}
// Don't move cursor - we should already be positioned at the start of our area
// Build complete output in memory for atomic rendering
var output strings.Builder
output.Grow(4096) // Pre-allocate larger buffer for box drawing
// Modern color scheme
const (
boxColor = "\033[96m" // Bright cyan for borders
headerColor = "\033[1;97m" // Bold white for headers
greenColor = "\033[32m" // Green for completed
blueColor = "\033[34m" // Blue for active
redColor = "\033[31m" // Red for errors
grayColor = "\033[90m" // Gray for skipped
resetColor = "\033[0m" // Reset colors
)
// Always render the complete 17-line box structure
// Line 1: Top border
p.renderBoxTop(&output, boxColor, resetColor)
// Line 2: Empty line
p.renderEmptyLine(&output, boxColor, resetColor)
// Lines 3-7: Items section (5 lines)
p.renderItemsSection(&output, boxColor, resetColor, greenColor, blueColor, redColor, grayColor)
// Line 8: Empty line
p.renderEmptyLine(&output, boxColor, resetColor)
// Line 9: API Status
p.renderAPIStatusSection(&output, boxColor, headerColor, resetColor, greenColor, redColor)
// Line 10: Rate Limit
p.renderRateLimitSection(&output, boxColor, headerColor, resetColor)
// Line 11: Empty line
p.renderEmptyLine(&output, boxColor, resetColor)
// Lines 12-17: Activity section (1 header + 5 log lines = 6 lines)
p.renderActivitySection(&output, boxColor, headerColor, resetColor, redColor)
// Line 17: Bottom border
p.renderBoxBottom(&output, boxColor, resetColor)
// Atomic rendering: write complete output in single operation
fmt.Print(output.String())
// Move cursor back to start of display area for next update
fmt.Print("\033[17A")
}
// renderBoxTop renders the top border with title
func (p *Progress) renderBoxTop(output *strings.Builder, boxColor, resetColor string) {
output.WriteString(boxColor)
output.WriteString("┌─ GitHub 🧠 pull ")
// Fill remaining space with dashes
titleLen := 17 // "GitHub 🧠 pull " length (emoji counts as 1 but displays as 2)
remainingDashes := p.boxWidth - titleLen - 2 // -2 for ┌ and ┐
for i := 0; i < remainingDashes; i++ {
output.WriteString("─")
}
output.WriteString("┐")
output.WriteString(resetColor)
output.WriteString("\033[K\n")
}