webctx 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENTS.md +97 -0
  2. package/CONTRIBUTORS.md +126 -0
  3. package/LICENSE +202 -0
  4. package/Makefile +69 -0
  5. package/README.md +121 -0
  6. package/bin/webctx.js +28 -0
  7. package/cmd/webctx/main.go +11 -0
  8. package/docs/porting-status.md +173 -0
  9. package/go.mod +3 -0
  10. package/internal/app/app.go +139 -0
  11. package/internal/app/app_test.go +194 -0
  12. package/internal/app/github.go +2084 -0
  13. package/internal/app/github_acceptance_test.go +166 -0
  14. package/internal/app/github_actions.go +1008 -0
  15. package/internal/app/github_actions_test.go +484 -0
  16. package/internal/app/github_activity_deployments.go +503 -0
  17. package/internal/app/github_activity_deployments_test.go +321 -0
  18. package/internal/app/github_commits.go +1004 -0
  19. package/internal/app/github_commits_test.go +503 -0
  20. package/internal/app/github_discussions_gists.go +1156 -0
  21. package/internal/app/github_discussions_gists_test.go +425 -0
  22. package/internal/app/github_issues.go +1616 -0
  23. package/internal/app/github_issues_test.go +731 -0
  24. package/internal/app/github_packages_projects.go +429 -0
  25. package/internal/app/github_packages_projects_test.go +168 -0
  26. package/internal/app/github_phase9_overview_stress_test.go +178 -0
  27. package/internal/app/github_pull_views.go +1141 -0
  28. package/internal/app/github_pull_views_test.go +603 -0
  29. package/internal/app/github_pulls.go +1459 -0
  30. package/internal/app/github_pulls_test.go +715 -0
  31. package/internal/app/github_refs.go +621 -0
  32. package/internal/app/github_refs_test.go +260 -0
  33. package/internal/app/github_search_profiles.go +505 -0
  34. package/internal/app/github_search_profiles_test.go +352 -0
  35. package/internal/app/github_test.go +888 -0
  36. package/internal/app/scrape.go +264 -0
  37. package/internal/app/tools.go +670 -0
  38. package/internal/buildinfo/buildinfo.go +16 -0
  39. package/package.json +66 -0
  40. package/scripts/postinstall.js +137 -0
@@ -0,0 +1,264 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "io"
7
+ "net/http"
8
+ "os"
9
+ "os/exec"
10
+ "path/filepath"
11
+ "runtime"
12
+ "strings"
13
+ "sync"
14
+ "time"
15
+ )
16
+
17
+ type MarkdownResult struct {
18
+ URL string
19
+ Title string
20
+ Markdown string
21
+ }
22
+
23
+ const keychainServiceName = "webctx"
24
+
25
+ var (
26
+ credentialEnvKeys = []string{"BRAVE_API_KEY", "TAVILY_API_KEY", "EXA_API_KEY", "FIRECRAWL_API_KEY", "GH_TOKEN", "GITHUB_TOKEN"}
27
+ getwdFunc = os.Getwd
28
+ executableFunc = os.Executable
29
+ keychainLookup = lookupKeychainSecret
30
+ )
31
+
32
+ func checkMarkdownAvailable(raw string) (bool, error) {
33
+ mdURL := raw
34
+ if !strings.HasSuffix(strings.ToLower(mdURL), ".md") {
35
+ mdURL += ".md"
36
+ }
37
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
38
+ defer cancel()
39
+ req, err := http.NewRequestWithContext(ctx, http.MethodHead, mdURL, nil)
40
+ if err != nil {
41
+ return false, err
42
+ }
43
+ resp, err := http.DefaultClient.Do(req)
44
+ if err != nil {
45
+ return false, err
46
+ }
47
+ defer resp.Body.Close()
48
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
49
+ return false, nil
50
+ }
51
+ contentType := strings.ToLower(resp.Header.Get("Content-Type"))
52
+ contentLength := resp.ContentLength
53
+ return (strings.Contains(contentType, "markdown") || strings.Contains(contentType, "text/plain")) && contentLength > 50, nil
54
+ }
55
+
56
+ func fetchMarkdownContent(raw string) (*MarkdownResult, error) {
57
+ mdURL := raw
58
+ if !strings.HasSuffix(strings.ToLower(mdURL), ".md") {
59
+ mdURL += ".md"
60
+ }
61
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
62
+ defer cancel()
63
+ content, status, err := fetchText(ctx, mdURL)
64
+ if err != nil {
65
+ return nil, err
66
+ }
67
+ if status < 200 || status >= 300 {
68
+ return nil, fmt.Errorf("Failed to fetch markdown: %d", status)
69
+ }
70
+ title := firstHeadingOrFallback(content, filepath.Base(raw))
71
+ return &MarkdownResult{URL: raw, Title: title, Markdown: content}, nil
72
+ }
73
+
74
+ func fetchText(ctx context.Context, rawURL string) (string, int, error) {
75
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
76
+ if err != nil {
77
+ return "", 0, err
78
+ }
79
+ resp, err := http.DefaultClient.Do(req)
80
+ if err != nil {
81
+ return "", 0, err
82
+ }
83
+ defer resp.Body.Close()
84
+ body, err := io.ReadAll(resp.Body)
85
+ if err != nil {
86
+ return "", resp.StatusCode, err
87
+ }
88
+ return string(body), resp.StatusCode, nil
89
+ }
90
+
91
+ func firstHeadingOrFallback(markdown, fallback string) string {
92
+ for _, line := range strings.Split(markdown, "\n") {
93
+ trimmed := strings.TrimSpace(line)
94
+ if strings.HasPrefix(trimmed, "# ") {
95
+ return strings.TrimSpace(strings.TrimPrefix(trimmed, "# "))
96
+ }
97
+ }
98
+ if strings.TrimSpace(fallback) == "" {
99
+ return "Document"
100
+ }
101
+ return fallback
102
+ }
103
+
104
+ type tokenBucketRateLimiter struct {
105
+ mu sync.Mutex
106
+ tokens int
107
+ maxTokens int
108
+ refillRate int
109
+ refillInterval time.Duration
110
+ lastRefill time.Time
111
+ }
112
+
113
+ func newFirecrawlRateLimiter() *tokenBucketRateLimiter {
114
+ return &tokenBucketRateLimiter{tokens: 10, maxTokens: 10, refillRate: 1, refillInterval: 6 * time.Second, lastRefill: time.Now()}
115
+ }
116
+
117
+ func (r *tokenBucketRateLimiter) refill() {
118
+ now := time.Now()
119
+ elapsed := now.Sub(r.lastRefill)
120
+ if elapsed < r.refillInterval {
121
+ return
122
+ }
123
+ intervals := int(elapsed / r.refillInterval)
124
+ if intervals <= 0 {
125
+ return
126
+ }
127
+ r.tokens += intervals * r.refillRate
128
+ if r.tokens > r.maxTokens {
129
+ r.tokens = r.maxTokens
130
+ }
131
+ r.lastRefill = r.lastRefill.Add(time.Duration(intervals) * r.refillInterval)
132
+ }
133
+
134
+ func (r *tokenBucketRateLimiter) acquire(ctx context.Context) error {
135
+ for {
136
+ r.mu.Lock()
137
+ r.refill()
138
+ if r.tokens > 0 {
139
+ r.tokens--
140
+ r.mu.Unlock()
141
+ return nil
142
+ }
143
+ wait := r.refillInterval - time.Since(r.lastRefill) + 100*time.Millisecond
144
+ r.mu.Unlock()
145
+ if wait < 100*time.Millisecond {
146
+ wait = 100 * time.Millisecond
147
+ }
148
+ select {
149
+ case <-ctx.Done():
150
+ return ctx.Err()
151
+ case <-time.After(wait):
152
+ }
153
+ }
154
+ }
155
+
156
+ type firecrawlQueue struct {
157
+ rateLimiter *tokenBucketRateLimiter
158
+ mu sync.Mutex
159
+ }
160
+
161
+ var (
162
+ queueOnce sync.Once
163
+ queueInst *firecrawlQueue
164
+ )
165
+
166
+ func getFirecrawlQueue() *firecrawlQueue {
167
+ queueOnce.Do(func() {
168
+ queueInst = &firecrawlQueue{rateLimiter: newFirecrawlRateLimiter()}
169
+ })
170
+ return queueInst
171
+ }
172
+
173
+ func (q *firecrawlQueue) enqueue(_ string, requestFn func() (map[string]any, error)) (map[string]any, error) {
174
+ q.mu.Lock()
175
+ defer q.mu.Unlock()
176
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
177
+ defer cancel()
178
+ if err := q.rateLimiter.acquire(ctx); err != nil {
179
+ return nil, err
180
+ }
181
+ return requestFn()
182
+ }
183
+
184
+ func loadEnvLocal() {
185
+ for _, candidate := range envLocalCandidates() {
186
+ loadDotEnvFile(candidate)
187
+ }
188
+ loadKeychainEnv()
189
+ }
190
+
191
+ func envLocalCandidates() []string {
192
+ candidates := []string{}
193
+ if exe, err := executableFunc(); err == nil {
194
+ exeDir := filepath.Dir(exe)
195
+ candidates = append(candidates, filepath.Join(exeDir, ".env.local"), filepath.Join(filepath.Dir(exeDir), ".env.local"))
196
+ }
197
+ if cwd, err := getwdFunc(); err == nil {
198
+ candidates = append(candidates, filepath.Join(cwd, ".env.local"))
199
+ }
200
+ seen := map[string]struct{}{}
201
+ unique := []string{}
202
+ for _, c := range candidates {
203
+ if _, ok := seen[c]; ok {
204
+ continue
205
+ }
206
+ seen[c] = struct{}{}
207
+ unique = append(unique, c)
208
+ }
209
+ return unique
210
+ }
211
+
212
+ func loadDotEnvFile(path string) {
213
+ data, err := os.ReadFile(path)
214
+ if err != nil {
215
+ return
216
+ }
217
+ for _, line := range strings.Split(string(data), "\n") {
218
+ trimmed := strings.TrimSpace(line)
219
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
220
+ continue
221
+ }
222
+ trimmed = strings.TrimPrefix(trimmed, "export ")
223
+ key, value, ok := strings.Cut(trimmed, "=")
224
+ if !ok {
225
+ continue
226
+ }
227
+ key = strings.TrimSpace(key)
228
+ value = strings.Trim(strings.TrimSpace(value), `"'`)
229
+ if key != "" {
230
+ if _, exists := os.LookupEnv(key); exists {
231
+ continue
232
+ }
233
+ _ = os.Setenv(key, value)
234
+ }
235
+ }
236
+ }
237
+
238
+ func loadKeychainEnv() {
239
+ for _, key := range credentialEnvKeys {
240
+ if _, exists := os.LookupEnv(key); exists {
241
+ continue
242
+ }
243
+ value, err := keychainLookup(key)
244
+ if err != nil || strings.TrimSpace(value) == "" {
245
+ continue
246
+ }
247
+ _ = os.Setenv(key, value)
248
+ }
249
+ }
250
+
251
+ func lookupKeychainSecret(account string) (string, error) {
252
+ if runtime.GOOS != "darwin" || strings.TrimSpace(account) == "" {
253
+ return "", nil
254
+ }
255
+
256
+ out, err := exec.Command("security", "find-generic-password", "-s", keychainServiceName, "-a", account, "-w").Output()
257
+ if err != nil {
258
+ if _, ok := err.(*exec.ExitError); ok {
259
+ return "", nil
260
+ }
261
+ return "", err
262
+ }
263
+ return strings.TrimSpace(string(out)), nil
264
+ }