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.
- package/AGENTS.md +97 -0
- package/CONTRIBUTORS.md +126 -0
- package/LICENSE +202 -0
- package/Makefile +69 -0
- package/README.md +121 -0
- package/bin/webctx.js +28 -0
- package/cmd/webctx/main.go +11 -0
- package/docs/porting-status.md +173 -0
- package/go.mod +3 -0
- package/internal/app/app.go +139 -0
- package/internal/app/app_test.go +194 -0
- package/internal/app/github.go +2084 -0
- package/internal/app/github_acceptance_test.go +166 -0
- package/internal/app/github_actions.go +1008 -0
- package/internal/app/github_actions_test.go +484 -0
- package/internal/app/github_activity_deployments.go +503 -0
- package/internal/app/github_activity_deployments_test.go +321 -0
- package/internal/app/github_commits.go +1004 -0
- package/internal/app/github_commits_test.go +503 -0
- package/internal/app/github_discussions_gists.go +1156 -0
- package/internal/app/github_discussions_gists_test.go +425 -0
- package/internal/app/github_issues.go +1616 -0
- package/internal/app/github_issues_test.go +731 -0
- package/internal/app/github_packages_projects.go +429 -0
- package/internal/app/github_packages_projects_test.go +168 -0
- package/internal/app/github_phase9_overview_stress_test.go +178 -0
- package/internal/app/github_pull_views.go +1141 -0
- package/internal/app/github_pull_views_test.go +603 -0
- package/internal/app/github_pulls.go +1459 -0
- package/internal/app/github_pulls_test.go +715 -0
- package/internal/app/github_refs.go +621 -0
- package/internal/app/github_refs_test.go +260 -0
- package/internal/app/github_search_profiles.go +505 -0
- package/internal/app/github_search_profiles_test.go +352 -0
- package/internal/app/github_test.go +888 -0
- package/internal/app/scrape.go +264 -0
- package/internal/app/tools.go +670 -0
- package/internal/buildinfo/buildinfo.go +16 -0
- package/package.json +66 -0
- 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
|
+
}
|