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,888 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/base64"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "os"
12
+ "strings"
13
+ "sync/atomic"
14
+ "testing"
15
+ "unicode/utf8"
16
+ )
17
+
18
+ type roundTripFunc func(*http.Request) (*http.Response, error)
19
+
20
+ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
21
+ return f(req)
22
+ }
23
+
24
+ func testHTTPResponse(req *http.Request, status int, body string, headers map[string]string) *http.Response {
25
+ h := make(http.Header)
26
+ for key, value := range headers {
27
+ h.Set(key, value)
28
+ }
29
+ return &http.Response{
30
+ StatusCode: status,
31
+ Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
32
+ Header: h,
33
+ Body: io.NopCloser(strings.NewReader(body)),
34
+ ContentLength: int64(len(body)),
35
+ Request: req,
36
+ }
37
+ }
38
+
39
+ func testGitHubClient(apiBase, rawBase, token string) *GitHubClient {
40
+ return &GitHubClient{
41
+ httpClient: http.DefaultClient,
42
+ apiBase: apiBase,
43
+ rawBase: rawBase,
44
+ token: token,
45
+ userAgent: "webctx-test",
46
+ }
47
+ }
48
+
49
+ func TestParseGitHubTargetTable(t *testing.T) {
50
+ tests := []struct {
51
+ name string
52
+ rawURL string
53
+ kind GitHubTargetKind
54
+ tail string
55
+ fragment string
56
+ supported bool
57
+ }{
58
+ {name: "repository root", rawURL: "https://github.com/amxv/webctx", kind: GitHubTargetRepository, supported: true},
59
+ {name: "repository root trailing slash", rawURL: "https://github.com/amxv/webctx/", kind: GitHubTargetRepository, supported: true},
60
+ {name: "blob line range", rawURL: "https://github.com/amxv/webctx/blob/main/internal/app/app.go#L20-L40", kind: GitHubTargetBlob, tail: "main/internal/app/app.go", fragment: "L20-L40", supported: true},
61
+ {name: "blob slash ref left unresolved", rawURL: "https://github.com/cli/cli/blob/andyfeller/test/README.md#installation", kind: GitHubTargetBlob, tail: "andyfeller/test/README.md", fragment: "installation", supported: true},
62
+ {name: "tree slash ref left unresolved", rawURL: "https://github.com/cli/cli/tree/andyfeller/test/docs", kind: GitHubTargetTree, tail: "andyfeller/test/docs", supported: true},
63
+ {name: "malformed blob remains authoritative family", rawURL: "https://github.com/amxv/webctx/blob/main", kind: GitHubTargetBlob, tail: "main", supported: true},
64
+ {name: "issue", rawURL: "https://github.com/amxv/webctx/issues/1", kind: GitHubTargetIssue, supported: true},
65
+ {name: "pull request conversation", rawURL: "https://github.com/amxv/webctx/pull/1", kind: GitHubTargetPull, supported: true},
66
+ {name: "pull request files", rawURL: "https://github.com/amxv/webctx/pull/1/files", kind: GitHubTargetPullFiles, supported: true},
67
+ {name: "pull request commits", rawURL: "https://github.com/amxv/webctx/pull/1/commits", kind: GitHubTargetPullCommits, supported: true},
68
+ {name: "pull request checks", rawURL: "https://github.com/amxv/webctx/pull/1/checks", kind: GitHubTargetPullChecks, supported: true},
69
+ {name: "pull request raw diff", rawURL: "https://github.com/amxv/webctx/pull/1.diff", kind: GitHubTargetPullDiff, supported: true},
70
+ {name: "pull request raw patch", rawURL: "https://github.com/amxv/webctx/pull/1.patch", kind: GitHubTargetPullPatch, supported: true},
71
+ {name: "security excluded", rawURL: "https://github.com/amxv/webctx/security/code-scanning", supported: false},
72
+ {name: "settings excluded", rawURL: "https://github.com/amxv/webctx/settings", supported: false},
73
+ {name: "profile", rawURL: "https://github.com/amxv", kind: GitHubTargetProfile, supported: true},
74
+ {name: "other host", rawURL: "https://example.com/amxv/webctx", supported: false},
75
+ }
76
+
77
+ for _, tt := range tests {
78
+ t.Run(tt.name, func(t *testing.T) {
79
+ target := parseGitHubTarget(tt.rawURL)
80
+ if !tt.supported {
81
+ if target != nil {
82
+ t.Fatalf("expected unsupported target, got %#v", target)
83
+ }
84
+ return
85
+ }
86
+ if target == nil {
87
+ t.Fatal("expected native GitHub target")
88
+ }
89
+ if target.Owner == "" || (target.Kind != GitHubTargetProfile && target.Repo == "") || target.Kind != tt.kind {
90
+ t.Fatalf("unexpected target: %#v", target)
91
+ }
92
+ if got := strings.Join(target.Tail, "/"); got != tt.tail {
93
+ t.Fatalf("tail mismatch: got %q want %q", got, tt.tail)
94
+ }
95
+ if target.Fragment != tt.fragment {
96
+ t.Fatalf("fragment mismatch: got %q want %q", target.Fragment, tt.fragment)
97
+ }
98
+ })
99
+ }
100
+ }
101
+
102
+ func TestGitHubClientRESTHeadersAndTokenPrecedence(t *testing.T) {
103
+ var seenAuth string
104
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105
+ seenAuth = r.Header.Get("Authorization")
106
+ if got := r.Header.Get("X-GitHub-Api-Version"); got != githubRESTAPIVersion {
107
+ t.Errorf("API version header: got %q want %q", got, githubRESTAPIVersion)
108
+ }
109
+ if got := r.Header.Get("Accept"); got != "application/vnd.github+json" {
110
+ t.Errorf("Accept header: got %q", got)
111
+ }
112
+ if got := r.Header.Get("User-Agent"); got != "webctx-test" {
113
+ t.Errorf("User-Agent: got %q", got)
114
+ }
115
+ w.Header().Set("Content-Type", "application/json")
116
+ _, _ = io.WriteString(w, `{}`)
117
+ }))
118
+ defer server.Close()
119
+
120
+ t.Setenv("GH_TOKEN", "gh-wins")
121
+ t.Setenv("GITHUB_TOKEN", "github-loses")
122
+ client := testGitHubClient(server.URL, server.URL, githubTokenFromEnv())
123
+ if _, err := client.REST(context.Background(), http.MethodGet, "/repos/o/r", ""); err != nil {
124
+ t.Fatal(err)
125
+ }
126
+ if seenAuth != "Bearer gh-wins" {
127
+ t.Fatalf("expected GH_TOKEN precedence, got %q", seenAuth)
128
+ }
129
+
130
+ t.Setenv("GH_TOKEN", "")
131
+ t.Setenv("GITHUB_TOKEN", "github-fallback")
132
+ client.token = githubTokenFromEnv()
133
+ if _, err := client.REST(context.Background(), http.MethodGet, "/repos/o/r", ""); err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ if seenAuth != "Bearer github-fallback" {
137
+ t.Fatalf("expected GITHUB_TOKEN fallback, got %q", seenAuth)
138
+ }
139
+ }
140
+
141
+ func TestGitHubClientRetriesPublicGETWithoutRejectedFineGrainedToken(t *testing.T) {
142
+ var authCalls, anonymousCalls int32
143
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144
+ if r.Header.Get("Authorization") != "" {
145
+ atomic.AddInt32(&authCalls, 1)
146
+ w.WriteHeader(http.StatusForbidden)
147
+ _, _ = io.WriteString(w, `{"message":"Resource not accessible by personal access token"}`)
148
+ return
149
+ }
150
+ atomic.AddInt32(&anonymousCalls, 1)
151
+ w.Header().Set("Content-Type", "application/json")
152
+ _, _ = io.WriteString(w, `[{"login":"public-user"}]`)
153
+ }))
154
+ defer server.Close()
155
+
156
+ client := testGitHubClient(server.URL, server.URL, "narrow-token")
157
+ resp, err := client.RESTPublic(context.Background(), http.MethodGet, "/repos/o/r/subscribers?per_page=30", "")
158
+ if err != nil {
159
+ t.Fatal(err)
160
+ }
161
+ if !strings.Contains(string(resp.Body), "public-user") {
162
+ t.Fatalf("anonymous retry body missing: %s", resp.Body)
163
+ }
164
+ if atomic.LoadInt32(&authCalls) != 1 || atomic.LoadInt32(&anonymousCalls) != 1 {
165
+ t.Fatalf("unexpected retry calls auth=%d anon=%d", authCalls, anonymousCalls)
166
+ }
167
+ }
168
+
169
+ func TestGitHubClientKeepsAuthenticatedErrorWhenAnonymousRetryAlsoFails(t *testing.T) {
170
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
171
+ if r.Header.Get("Authorization") != "" {
172
+ w.WriteHeader(http.StatusForbidden)
173
+ _, _ = io.WriteString(w, `{"message":"Resource not accessible by personal access token"}`)
174
+ return
175
+ }
176
+ w.WriteHeader(http.StatusUnauthorized)
177
+ _, _ = io.WriteString(w, `{"message":"Requires authentication"}`)
178
+ }))
179
+ defer server.Close()
180
+ client := testGitHubClient(server.URL, server.URL, "narrow-token")
181
+ _, err := client.RESTPublic(context.Background(), http.MethodGet, "/orgs/o/packages/container/p", "")
182
+ if err == nil || !strings.Contains(err.Error(), "configured token permissions") {
183
+ t.Fatalf("expected original fine-grained permission error, got %v", err)
184
+ }
185
+ }
186
+
187
+ func TestGitHubClientNeverSendsTokenToRawOrigin(t *testing.T) {
188
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
189
+ if got := r.Header.Get("Authorization"); got != "" {
190
+ t.Errorf("raw request leaked Authorization header: %q", got)
191
+ }
192
+ _, _ = io.WriteString(w, "plain text")
193
+ }))
194
+ defer server.Close()
195
+ client := testGitHubClient("https://api.example.test", server.URL, "fake-secret-token")
196
+ if _, err := client.Raw(context.Background(), server.URL+"/o/r/main/file.txt"); err != nil {
197
+ t.Fatal(err)
198
+ }
199
+ }
200
+
201
+ func TestGitHubLinkPaginationPrimitives(t *testing.T) {
202
+ header := `<https://api.github.com/resource?page=1>; rel="prev", <https://api.github.com/resource?page=3>; rel="next", <https://api.github.com/resource?page=9>; rel="last"`
203
+ links := ParseGitHubLinkHeader(header)
204
+ if links["prev"] != "https://api.github.com/resource?page=1" || links["next"] != "https://api.github.com/resource?page=3" || links["last"] != "https://api.github.com/resource?page=9" {
205
+ t.Fatalf("unexpected links: %#v", links)
206
+ }
207
+ resp := GitHubResponse{Header: http.Header{"Link": []string{header}}}
208
+ if got := resp.Links()["next"]; got != links["next"] {
209
+ t.Fatalf("response Links mismatch: %q", got)
210
+ }
211
+ }
212
+
213
+ func TestGitHubClientErrorClassificationAndSecretNonLeakage(t *testing.T) {
214
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
215
+ switch r.URL.Path {
216
+ case "/not-found":
217
+ w.WriteHeader(http.StatusNotFound)
218
+ _, _ = io.WriteString(w, `{"message":"Not Found"}`)
219
+ case "/forbidden":
220
+ w.WriteHeader(http.StatusForbidden)
221
+ _, _ = io.WriteString(w, `{"message":"Resource not accessible"}`)
222
+ case "/rate":
223
+ w.Header().Set("X-RateLimit-Remaining", "0")
224
+ w.Header().Set("X-RateLimit-Reset", "1893456000")
225
+ w.Header().Set("X-RateLimit-Resource", "core")
226
+ w.WriteHeader(http.StatusForbidden)
227
+ _, _ = io.WriteString(w, `{"message":"API rate limit exceeded"}`)
228
+ case "/secondary":
229
+ w.Header().Set("Retry-After", "60")
230
+ w.WriteHeader(http.StatusTooManyRequests)
231
+ _, _ = io.WriteString(w, `{"message":"secondary rate limit"}`)
232
+ case "/provider-limit":
233
+ w.WriteHeader(http.StatusForbidden)
234
+ _, _ = io.WriteString(w, `{"message":"This API returns blobs up to 100 MB in size"}`)
235
+ case "/auth":
236
+ w.WriteHeader(http.StatusUnauthorized)
237
+ _, _ = io.WriteString(w, `{"message":"fake-secret-token must never appear"}`)
238
+ default:
239
+ w.WriteHeader(http.StatusInternalServerError)
240
+ }
241
+ }))
242
+ defer server.Close()
243
+
244
+ client := testGitHubClient(server.URL, server.URL, "")
245
+ tests := []struct {
246
+ path string
247
+ kind GitHubErrorKind
248
+ }{
249
+ {path: "/not-found", kind: GitHubErrorNotFound},
250
+ {path: "/forbidden", kind: GitHubErrorForbidden},
251
+ {path: "/rate", kind: GitHubErrorRateLimit},
252
+ {path: "/secondary", kind: GitHubErrorRateLimit},
253
+ {path: "/provider-limit", kind: GitHubErrorProviderLimit},
254
+ }
255
+ for _, tt := range tests {
256
+ _, err := client.REST(context.Background(), http.MethodGet, tt.path, "")
257
+ if err == nil {
258
+ t.Fatalf("%s: expected error", tt.path)
259
+ }
260
+ ghErr, ok := err.(*GitHubError)
261
+ if !ok || ghErr.Kind != tt.kind {
262
+ t.Fatalf("%s: got %#v, want kind %s", tt.path, err, tt.kind)
263
+ }
264
+ }
265
+
266
+ _, requiredErr := client.REST(context.Background(), http.MethodGet, "/auth", "")
267
+ if requiredErr == nil || !strings.Contains(requiredErr.Error(), "authentication is required") {
268
+ t.Fatalf("no-token 401 wording is not truthful: %v", requiredErr)
269
+ }
270
+
271
+ _, rateErr := client.REST(context.Background(), http.MethodGet, "/rate", "")
272
+ if !strings.Contains(rateErr.Error(), "Resource: core") || !strings.Contains(rateErr.Error(), "Reset:") || !strings.Contains(rateErr.Error(), "GH_TOKEN") {
273
+ t.Fatalf("rate error missing truthful context: %q", rateErr)
274
+ }
275
+ _, secondaryErr := client.REST(context.Background(), http.MethodGet, "/secondary", "")
276
+ if !strings.Contains(secondaryErr.Error(), "Retry after: 60") {
277
+ t.Fatalf("secondary limit missing retry context: %q", secondaryErr)
278
+ }
279
+
280
+ client.token = "fake-secret-token"
281
+ _, authErr := client.REST(context.Background(), http.MethodGet, "/auth", "")
282
+ if authErr == nil {
283
+ t.Fatal("expected auth error")
284
+ }
285
+ if strings.Contains(authErr.Error(), "fake-secret-token") {
286
+ t.Fatalf("secret leaked through error: %q", authErr)
287
+ }
288
+ }
289
+
290
+ func TestResolveGitHubRefPathSlashRef(t *testing.T) {
291
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
292
+ ref := r.URL.Query().Get("ref")
293
+ if ref == "feature/docs" && r.URL.Path == "/repos/o/r/contents/guide.md" {
294
+ w.Header().Set("Content-Type", "application/json")
295
+ _, _ = io.WriteString(w, `{"type":"file","name":"guide.md","path":"guide.md","size":10}`)
296
+ return
297
+ }
298
+ w.WriteHeader(http.StatusNotFound)
299
+ _, _ = io.WriteString(w, `{"message":"Not Found"}`)
300
+ }))
301
+ defer server.Close()
302
+
303
+ target := parseGitHubTarget("https://github.com/o/r/blob/feature/docs/guide.md")
304
+ resolved, err := resolveGitHubRefPath(context.Background(), testGitHubClient(server.URL, server.URL, ""), target, "file")
305
+ if err != nil {
306
+ t.Fatal(err)
307
+ }
308
+ if resolved.Ref != "feature/docs" || resolved.Path != "guide.md" {
309
+ t.Fatalf("unexpected resolution: %#v", resolved)
310
+ }
311
+ }
312
+
313
+ func TestResolveGitHubRefPathDetectsOverlappingAmbiguity(t *testing.T) {
314
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
315
+ ref := r.URL.Query().Get("ref")
316
+ valid := (ref == "feature" && r.URL.Path == "/repos/o/r/contents/docs/guide.md") || (ref == "feature/docs" && r.URL.Path == "/repos/o/r/contents/guide.md")
317
+ if valid {
318
+ _, _ = io.WriteString(w, `{"type":"file","name":"guide.md","path":"guide.md","size":10}`)
319
+ return
320
+ }
321
+ w.WriteHeader(http.StatusNotFound)
322
+ _, _ = io.WriteString(w, `{"message":"Not Found"}`)
323
+ }))
324
+ defer server.Close()
325
+
326
+ target := parseGitHubTarget("https://github.com/o/r/blob/feature/docs/guide.md")
327
+ _, err := resolveGitHubRefPath(context.Background(), testGitHubClient(server.URL, server.URL, ""), target, "file")
328
+ if err == nil || !strings.Contains(err.Error(), "ambiguous") || !strings.Contains(err.Error(), "feature/docs") {
329
+ t.Fatalf("expected truthful ambiguity, got %v", err)
330
+ }
331
+ }
332
+
333
+ func TestGitHubRepositoryRootPreviewAndHints(t *testing.T) {
334
+ preFence := "# webctx\n\n<!-- hidden-root-marker -->\n\n" + strings.Repeat("A useful paragraph with enough words to exercise the repository preview boundary.\n\n", 61)
335
+ longReadme := preFence + "```go\n" + strings.Repeat("fmt.Println(\"inside a long fenced block\")\n", 80) + "```\n\n## After\nnot in preview\n"
336
+ encoded := base64.StdEncoding.EncodeToString([]byte(longReadme))
337
+ readmeURL := "https://github.com/amxv/webctx/blob/main/README.md"
338
+
339
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
340
+ switch r.URL.Path {
341
+ case "/repos/amxv/webctx":
342
+ _, _ = io.WriteString(w, `{"full_name":"amxv/webctx","html_url":"https://github.com/amxv/webctx","description":"fast & clean context","language":"Go","default_branch":"main","stargazers_count":7,"watchers_count":999,"topics":["web","agents"],"archived":false,"fork":false,"visibility":"public","license":{"spdx_id":"Apache-2.0","name":"Apache License 2.0"}}`)
343
+ case "/repos/amxv/webctx/readme":
344
+ payload := githubContent{Type: "file", Name: "README.md", Path: "README.md", Size: int64(len(longReadme)), Encoding: "base64", Content: encoded, HTMLURL: readmeURL, DownloadURL: "https://raw.githubusercontent.com/amxv/webctx/main/README.md"}
345
+ _ = json.NewEncoder(w).Encode(payload)
346
+ default:
347
+ w.WriteHeader(http.StatusNotFound)
348
+ }
349
+ }))
350
+ defer api.Close()
351
+
352
+ target := parseGitHubTarget("https://github.com/amxv/webctx")
353
+ result := readGitHubNativeWithClient(context.Background(), target, testGitHubClient(api.URL, api.URL, ""))
354
+ if result.Outcome != GitHubNativeSuccess {
355
+ t.Fatalf("root read failed: %v", result.Err)
356
+ }
357
+ out := result.Markdown
358
+ if !strings.HasPrefix(out, "---\nrepository: \"amxv/webctx\"") {
359
+ t.Fatalf("root output does not begin with frontmatter: %q", out[:min(len(out), 120)])
360
+ }
361
+ for _, want := range []string{"description: \"fast & clean context\"", "language: \"Go\"", "default_branch: \"main\"", "license: \"Apache-2.0\"", "stars: 7", "topics: [\"agents\",\"web\"]"} {
362
+ if !strings.Contains(out, want) {
363
+ t.Errorf("missing frontmatter field %q", want)
364
+ }
365
+ }
366
+ if strings.Contains(out, "watchers") || strings.Contains(out, "999") {
367
+ t.Fatalf("watchers alias leaked into root metadata: %q", out)
368
+ }
369
+ if strings.Contains(out, "hidden-root-marker") {
370
+ t.Fatal("human repository preview retained invisible HTML comment")
371
+ }
372
+ if !strings.Contains(out, "README preview truncated") || !strings.Contains(out, "Full README: "+readmeURL) {
373
+ t.Fatalf("missing truncation/full README direction: %q", out)
374
+ }
375
+ if strings.Count(out, "```")%2 != 0 {
376
+ t.Fatal("root preview ended inside an open Markdown fence")
377
+ }
378
+ previewStart := strings.Index(out, "## README preview\n\n")
379
+ previewEnd := strings.Index(out, "\n\n> README preview truncated")
380
+ if previewStart < 0 || previewEnd < 0 {
381
+ t.Fatal("could not locate README preview boundaries")
382
+ }
383
+ preview := out[previewStart+len("## README preview\n\n") : previewEnd]
384
+ if got := utf8.RuneCountInString(preview); got > githubRootPreviewRunes {
385
+ t.Fatalf("preview exceeds rune budget: %d", got)
386
+ }
387
+ for _, want := range []string{"#L20-L40", "/tree/main/path/to/directory", "/issues", "/pull/123"} {
388
+ if !strings.Contains(out, want) {
389
+ t.Errorf("missing useful native hint %q", want)
390
+ }
391
+ }
392
+ if strings.Count(out[strings.Index(out, "## Useful GitHub URLs"):], "\n-") > 5 {
393
+ t.Fatal("root native hints exceeded the concise orientation budget")
394
+ }
395
+ if strings.Contains(out, "GH_TOKEN") || strings.Contains(out, "GITHUB_TOKEN") {
396
+ t.Fatal("successful anonymous root read nags about authentication")
397
+ }
398
+ }
399
+
400
+ func TestGitHubRepositoryRootDoesNotFalseTruncate(t *testing.T) {
401
+ readme := "# Tiny\n\nvisible\n<!-- hidden -->\n\n```html\n<!-- visible-in-code-fence -->\n```\n"
402
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
403
+ switch r.URL.Path {
404
+ case "/repos/o/r":
405
+ _, _ = io.WriteString(w, `{"full_name":"o/r","html_url":"https://github.com/o/r","default_branch":"main","stargazers_count":0}`)
406
+ case "/repos/o/r/readme":
407
+ _ = json.NewEncoder(w).Encode(githubContent{Type: "file", Name: "README.md", Path: "README.md", Size: int64(len(readme)), Encoding: "base64", Content: base64.StdEncoding.EncodeToString([]byte(readme)), HTMLURL: "https://github.com/o/r/blob/main/README.md"})
408
+ default:
409
+ w.WriteHeader(http.StatusNotFound)
410
+ }
411
+ }))
412
+ defer api.Close()
413
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r"), testGitHubClient(api.URL, api.URL, ""))
414
+ if result.Err != nil {
415
+ t.Fatal(result.Err)
416
+ }
417
+ if strings.Contains(result.Markdown, "preview truncated") {
418
+ t.Fatal("small README was falsely marked truncated")
419
+ }
420
+ if strings.Contains(result.Markdown, "<!-- hidden -->") || !strings.Contains(result.Markdown, "visible") {
421
+ t.Fatalf("unexpected human README sanitization: %q", result.Markdown)
422
+ }
423
+ if !strings.Contains(result.Markdown, "visible-in-code-fence") {
424
+ t.Fatalf("human-visible fenced comment was stripped: %q", result.Markdown)
425
+ }
426
+ if !strings.Contains(result.Markdown, "stars: 0") {
427
+ t.Fatal("zero star count should remain useful metadata")
428
+ }
429
+ }
430
+
431
+ func TestGitHubPublicBlobRawFastPathPreservesSourceComments(t *testing.T) {
432
+ var apiCalls int
433
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
434
+ apiCalls++
435
+ w.WriteHeader(http.StatusInternalServerError)
436
+ }))
437
+ defer api.Close()
438
+ raw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
439
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
440
+ _, _ = io.WriteString(w, "# Source\n\n<!-- keep-source-comment -->\nbody\n")
441
+ }))
442
+ defer raw.Close()
443
+
444
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/blob/feature/docs/README.md"), testGitHubClient(api.URL, raw.URL, ""))
445
+ if result.Err != nil {
446
+ t.Fatal(result.Err)
447
+ }
448
+ if !strings.Contains(result.Markdown, "keep-source-comment") {
449
+ t.Fatal("direct source read stripped an HTML comment")
450
+ }
451
+ if apiCalls != 0 {
452
+ t.Fatalf("public raw fast path spent %d REST requests", apiCalls)
453
+ }
454
+ }
455
+
456
+ func TestGitHubBlobLineSelectors(t *testing.T) {
457
+ raw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
458
+ w.Header().Set("Content-Type", "text/plain")
459
+ _, _ = io.WriteString(w, "alpha-only\nbeta-only\ngamma-only\ndelta-only\n")
460
+ }))
461
+ defer raw.Close()
462
+ client := testGitHubClient("https://api.example.test", raw.URL, "")
463
+
464
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/blob/main/file.go#L2-L3"), client)
465
+ if result.Err != nil {
466
+ t.Fatal(result.Err)
467
+ }
468
+ if !strings.Contains(result.Markdown, "beta-only\ngamma-only") || strings.Contains(result.Markdown, "alpha-only") || strings.Contains(result.Markdown, "delta-only") {
469
+ t.Fatalf("line range was not narrowed: %q", result.Markdown)
470
+ }
471
+ if !strings.Contains(result.Markdown, "**Lines:** 2-3 of 4") {
472
+ t.Fatalf("missing source coordinates: %q", result.Markdown)
473
+ }
474
+
475
+ for _, fragment := range []string{"L3-L2", "L0", "L2-L9"} {
476
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/blob/main/file.go#"+fragment), client)
477
+ if result.Outcome != GitHubNativeFailure || result.Err == nil {
478
+ t.Fatalf("%s: expected truthful selector failure, got %#v", fragment, result)
479
+ }
480
+ }
481
+ }
482
+
483
+ func TestGitHubBlobMarkdownHeadingSelectors(t *testing.T) {
484
+ content := "# Guide\nintro\n\n## Install\nfirst install\n### Details\nchild\n\n## Usage\nusage body\n\n## Install\nsecond install\n\n## End\nend\n"
485
+ raw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
486
+ w.Header().Set("Content-Type", "text/plain")
487
+ _, _ = io.WriteString(w, content)
488
+ }))
489
+ defer raw.Close()
490
+ client := testGitHubClient("https://api.example.test", raw.URL, "")
491
+
492
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/blob/main/README.md#install"), client)
493
+ if result.Err != nil {
494
+ t.Fatal(result.Err)
495
+ }
496
+ if !strings.Contains(result.Markdown, "first install") || !strings.Contains(result.Markdown, "### Details") || strings.Contains(result.Markdown, "usage body") || strings.Contains(result.Markdown, "second install") {
497
+ t.Fatalf("heading section was not bounded correctly: %q", result.Markdown)
498
+ }
499
+
500
+ second := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/blob/main/README.md#install-1"), client)
501
+ if second.Err != nil || !strings.Contains(second.Markdown, "second install") || strings.Contains(second.Markdown, "first install") {
502
+ t.Fatalf("duplicate heading selector failed: %#v", second)
503
+ }
504
+
505
+ missing := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/blob/main/README.md#does-not-exist"), client)
506
+ if missing.Outcome != GitHubNativeFailure || missing.Err == nil || !strings.Contains(missing.Err.Error(), "was not found") {
507
+ t.Fatalf("missing heading did not fail truthfully: %#v", missing)
508
+ }
509
+ }
510
+
511
+ func TestGitHubBlobBinaryAndProviderLimit(t *testing.T) {
512
+ binaryRaw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
513
+ w.Header().Set("Content-Type", "image/png")
514
+ _, _ = w.Write([]byte("not-real-png-but-explicitly-binary-mime"))
515
+ }))
516
+ defer binaryRaw.Close()
517
+ binaryResult := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/blob/main/image.png"), testGitHubClient("https://api.example.test", binaryRaw.URL, ""))
518
+ if binaryResult.Err != nil || !strings.Contains(binaryResult.Markdown, "Binary/non-text source") || !strings.Contains(binaryResult.Markdown, "Raw/download:") {
519
+ t.Fatalf("binary handling failed: %#v", binaryResult)
520
+ }
521
+
522
+ client := &GitHubClient{
523
+ httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
524
+ resp := testHTTPResponse(req, http.StatusOK, "", map[string]string{"Content-Type": "text/plain"})
525
+ resp.ContentLength = githubBlobMaxBytes + 1
526
+ return resp, nil
527
+ })},
528
+ apiBase: "https://api.example.test",
529
+ rawBase: "https://raw.example.test",
530
+ userAgent: "webctx-test",
531
+ }
532
+ limited := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/blob/main/huge.txt"), client)
533
+ if limited.Outcome != GitHubNativeFailure || limited.Err == nil || !strings.Contains(limited.Err.Error(), "100 MB") {
534
+ t.Fatalf("provider-size limit not surfaced: %#v", limited)
535
+ }
536
+ }
537
+
538
+ func TestGitHubPrivateBlobUsesAuthenticatedContentsFallback(t *testing.T) {
539
+ raw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
540
+ if r.Header.Get("Authorization") != "" {
541
+ t.Error("token leaked to raw host")
542
+ }
543
+ w.WriteHeader(http.StatusNotFound)
544
+ _, _ = io.WriteString(w, "not found")
545
+ }))
546
+ defer raw.Close()
547
+
548
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
549
+ if got := r.Header.Get("Authorization"); got != "Bearer fake-private-token" {
550
+ t.Errorf("authenticated contents request missing token: %q", got)
551
+ }
552
+ ref := r.URL.Query().Get("ref")
553
+ if ref == "private/main" && r.URL.Path == "/repos/o/r/contents/secret.md" {
554
+ content := "# Secret source\n<!-- private-source-comment -->\ncontent\n"
555
+ _ = json.NewEncoder(w).Encode(githubContent{Type: "file", Name: "secret.md", Path: "secret.md", Size: int64(len(content)), Encoding: "base64", Content: base64.StdEncoding.EncodeToString([]byte(content)), HTMLURL: "https://github.com/o/r/blob/private/main/secret.md"})
556
+ return
557
+ }
558
+ w.WriteHeader(http.StatusNotFound)
559
+ _, _ = io.WriteString(w, `{"message":"Not Found"}`)
560
+ }))
561
+ defer api.Close()
562
+
563
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/blob/private/main/secret.md"), testGitHubClient(api.URL, raw.URL, "fake-private-token"))
564
+ if result.Err != nil {
565
+ t.Fatal(result.Err)
566
+ }
567
+ if !strings.Contains(result.Markdown, "Secret source") || !strings.Contains(result.Markdown, "private-source-comment") {
568
+ t.Fatalf("authenticated source fallback lost content: %q", result.Markdown)
569
+ }
570
+ }
571
+
572
+ func TestGitHubPrivateBlobProviderSizeMetadata(t *testing.T) {
573
+ raw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
574
+ w.WriteHeader(http.StatusNotFound)
575
+ }))
576
+ defer raw.Close()
577
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
578
+ ref := r.URL.Query().Get("ref")
579
+ if ref == "main" && r.URL.Path == "/repos/o/r/contents/huge.bin" {
580
+ _ = json.NewEncoder(w).Encode(githubContent{Type: "file", Name: "huge.bin", Path: "huge.bin", Size: githubBlobMaxBytes + 1})
581
+ return
582
+ }
583
+ w.WriteHeader(http.StatusNotFound)
584
+ }))
585
+ defer api.Close()
586
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/blob/main/huge.bin"), testGitHubClient(api.URL, raw.URL, "fake-token"))
587
+ if result.Outcome != GitHubNativeFailure || result.Err == nil || !strings.Contains(result.Err.Error(), "100 MB") {
588
+ t.Fatalf("large authenticated source not surfaced: %#v", result)
589
+ }
590
+ }
591
+
592
+ func TestGitHubTreeSlashRefOneLevelREADME(t *testing.T) {
593
+ readmeURL := "https://github.com/o/r/blob/feature/docs/subdir/README.md"
594
+ readme := "# Directory\n\n<!-- hidden-tree-marker -->\n\n" + strings.Repeat("directory paragraph with useful context\n\n", 180)
595
+ var requested []string
596
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
597
+ requested = append(requested, r.URL.Path+"?ref="+r.URL.Query().Get("ref")+" accept="+r.Header.Get("Accept"))
598
+ ref := r.URL.Query().Get("ref")
599
+ if ref == "feature/docs" && r.URL.Path == "/repos/o/r/contents/subdir" {
600
+ entries := []githubContent{
601
+ {Type: "file", Name: "a.txt", Path: "subdir/a.txt", Size: 4, HTMLURL: "https://github.com/o/r/blob/feature/docs/subdir/a.txt"},
602
+ {Type: "dir", Name: "nested", Path: "subdir/nested", HTMLURL: "https://github.com/o/r/tree/feature/docs/subdir/nested"},
603
+ {Type: "file", Name: "README.md", Path: "subdir/README.md", Size: int64(len(readme)), HTMLURL: readmeURL},
604
+ }
605
+ _ = json.NewEncoder(w).Encode(entries)
606
+ return
607
+ }
608
+ if ref == "feature/docs" && r.URL.Path == "/repos/o/r/contents/subdir/README.md" && r.Header.Get("Accept") == "application/vnd.github.raw+json" {
609
+ w.Header().Set("Content-Type", "text/plain")
610
+ _, _ = io.WriteString(w, readme)
611
+ return
612
+ }
613
+ w.WriteHeader(http.StatusNotFound)
614
+ _, _ = io.WriteString(w, `{"message":"Not Found"}`)
615
+ }))
616
+ defer api.Close()
617
+
618
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/tree/feature/docs/subdir"), testGitHubClient(api.URL, api.URL, ""))
619
+ if result.Err != nil {
620
+ t.Fatal(result.Err)
621
+ }
622
+ out := result.Markdown
623
+ for _, want := range []string{"ref: \"feature/docs\"", "path: \"subdir\"", "- file [a.txt]", "- dir [nested/]", "## Directory README", "Directory README preview truncated", "Full README: " + readmeURL} {
624
+ if !strings.Contains(out, want) {
625
+ t.Errorf("tree output missing %q", want)
626
+ }
627
+ }
628
+ if strings.Contains(out, "hidden-tree-marker") {
629
+ t.Fatal("tree human-view README retained hidden comment")
630
+ }
631
+ for _, req := range requested {
632
+ if strings.Contains(req, "/nested/") {
633
+ t.Fatalf("tree reader recursed unexpectedly: %s", req)
634
+ }
635
+ }
636
+ }
637
+
638
+ func TestGitHubTreeProviderCeilingIsTruthful(t *testing.T) {
639
+ entries := make([]githubContent, 1000)
640
+ for i := range entries {
641
+ entries[i] = githubContent{Type: "file", Name: fmt.Sprintf("file-%04d.txt", i), Path: fmt.Sprintf("dir/file-%04d.txt", i), HTMLURL: fmt.Sprintf("https://github.com/o/r/blob/main/dir/file-%04d.txt", i)}
642
+ }
643
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
644
+ if r.URL.Query().Get("ref") == "main" && r.URL.Path == "/repos/o/r/contents/dir" {
645
+ _ = json.NewEncoder(w).Encode(entries)
646
+ return
647
+ }
648
+ w.WriteHeader(http.StatusNotFound)
649
+ _, _ = io.WriteString(w, `{"message":"Not Found"}`)
650
+ }))
651
+ defer api.Close()
652
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/tree/main/dir"), testGitHubClient(api.URL, api.URL, ""))
653
+ if result.Err != nil {
654
+ t.Fatal(result.Err)
655
+ }
656
+ if got := utf8.RuneCountInString(result.Markdown); got > githubOverviewRunes {
657
+ t.Fatalf("directory overview exceeded shared target: %d runes", got)
658
+ }
659
+ if !strings.Contains(result.Markdown, "complete: false") || !strings.Contains(result.Markdown, "provider_result_ceiling: 1000") || !strings.Contains(result.Markdown, "entries_returned: 1000") || !strings.Contains(result.Markdown, "entries_local_omitted:") || !strings.Contains(result.Markdown, "locally omitted from this overview") || !strings.Contains(result.Markdown, "at most 1,000 entries") {
660
+ t.Fatalf("directory provider ceiling not surfaced: %q", result.Markdown[:min(len(result.Markdown), 500)])
661
+ }
662
+ }
663
+
664
+ func TestGitHubTokenKeysUseDotEnvAndKeychainPrecedence(t *testing.T) {
665
+ tmp := t.TempDir()
666
+ envPath := tmp + "/.env.local"
667
+ if err := os.WriteFile(envPath, []byte("GH_TOKEN=from-file-gh\nGITHUB_TOKEN=from-file-github\n"), 0o600); err != nil {
668
+ t.Fatal(err)
669
+ }
670
+ t.Setenv("GH_TOKEN", "from-process")
671
+ t.Setenv("GITHUB_TOKEN", "")
672
+ loadDotEnvFile(envPath)
673
+ if got := os.Getenv("GH_TOKEN"); got != "from-process" {
674
+ t.Fatalf("process GH_TOKEN did not win: %q", got)
675
+ }
676
+ if got := os.Getenv("GITHUB_TOKEN"); got != "" {
677
+ t.Fatalf("explicitly empty GITHUB_TOKEN was overwritten: %q", got)
678
+ }
679
+
680
+ oldGH, hadGH := os.LookupEnv("GH_TOKEN")
681
+ if err := os.Unsetenv("GH_TOKEN"); err != nil {
682
+ t.Fatal(err)
683
+ }
684
+ t.Cleanup(func() {
685
+ if hadGH {
686
+ _ = os.Setenv("GH_TOKEN", oldGH)
687
+ } else {
688
+ _ = os.Unsetenv("GH_TOKEN")
689
+ }
690
+ })
691
+ for _, key := range []string{"BRAVE_API_KEY", "TAVILY_API_KEY", "EXA_API_KEY", "FIRECRAWL_API_KEY", "GITHUB_TOKEN"} {
692
+ t.Setenv(key, "")
693
+ }
694
+ originalLookup := keychainLookup
695
+ t.Cleanup(func() { keychainLookup = originalLookup })
696
+ keychainLookup = func(key string) (string, error) {
697
+ if key == "GH_TOKEN" {
698
+ return "from-keychain-gh", nil
699
+ }
700
+ return "", nil
701
+ }
702
+ loadKeychainEnv()
703
+ if got := os.Getenv("GH_TOKEN"); got != "from-keychain-gh" {
704
+ t.Fatalf("GitHub token was not loaded from Keychain seam: %q", got)
705
+ }
706
+ }
707
+
708
+ func TestReadLinkDirectMarkdownRegression(t *testing.T) {
709
+ originalClient := http.DefaultClient
710
+ t.Cleanup(func() { http.DefaultClient = originalClient })
711
+ var firecrawlCalled bool
712
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
713
+ switch {
714
+ case req.Method == http.MethodHead && req.URL.String() == "https://docs.example/guide.md":
715
+ resp := testHTTPResponse(req, http.StatusOK, "", map[string]string{"Content-Type": "text/plain"})
716
+ resp.ContentLength = 100
717
+ return resp, nil
718
+ case req.Method == http.MethodGet && req.URL.String() == "https://docs.example/guide.md":
719
+ return testHTTPResponse(req, http.StatusOK, "# Guide\n\ndirect markdown body\n", map[string]string{"Content-Type": "text/plain"}), nil
720
+ case strings.Contains(req.URL.Host, "firecrawl"):
721
+ firecrawlCalled = true
722
+ return testHTTPResponse(req, http.StatusInternalServerError, "", nil), nil
723
+ default:
724
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
725
+ }
726
+ })}
727
+ t.Setenv("FIRECRAWL_API_KEY", "")
728
+ out, err := ReadLink("https://docs.example/guide")
729
+ if err != nil {
730
+ t.Fatal(err)
731
+ }
732
+ if !strings.Contains(out, "direct markdown body") || firecrawlCalled {
733
+ t.Fatalf("direct Markdown fallback regressed: %q", out)
734
+ }
735
+ }
736
+
737
+ func TestReadLinkUnsupportedGitHubFallsThroughToFirecrawlWithExistingSettings(t *testing.T) {
738
+ originalClient := http.DefaultClient
739
+ t.Cleanup(func() { http.DefaultClient = originalClient })
740
+ var firecrawlPayload map[string]any
741
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
742
+ if req.Method == http.MethodHead && req.URL.Host == "github.com" {
743
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
744
+ }
745
+ if req.Method == http.MethodPost && req.URL.String() == "https://api.firecrawl.dev/v2/scrape" {
746
+ body, _ := io.ReadAll(req.Body)
747
+ _ = json.Unmarshal(body, &firecrawlPayload)
748
+ response := `{"success":true,"data":{"metadata":{"title":"Wiki page"},"markdown":"scraped wiki body"}}`
749
+ return testHTTPResponse(req, http.StatusOK, response, map[string]string{"Content-Type": "application/json"}), nil
750
+ }
751
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
752
+ })}
753
+ t.Setenv("FIRECRAWL_API_KEY", "fake-firecrawl-key")
754
+ out, err := ReadLink("https://github.com/o/r/wiki/Guide")
755
+ if err != nil {
756
+ t.Fatal(err)
757
+ }
758
+ if !strings.Contains(out, "scraped wiki body") {
759
+ t.Fatalf("unsupported GitHub route was swallowed: %q", out)
760
+ }
761
+ for key, want := range map[string]any{
762
+ "onlyMainContent": true,
763
+ "skipTlsVerification": true,
764
+ "blockAds": true,
765
+ "removeBase64Images": true,
766
+ "maxAge": float64(600000),
767
+ } {
768
+ if got := firecrawlPayload[key]; got != want {
769
+ t.Errorf("Firecrawl setting %s changed: got %#v want %#v", key, got, want)
770
+ }
771
+ }
772
+ formats, _ := firecrawlPayload["formats"].([]any)
773
+ if len(formats) != 1 || formats[0] != "markdown" {
774
+ t.Fatalf("Firecrawl formats changed: %#v", firecrawlPayload["formats"])
775
+ }
776
+ if _, ok := firecrawlPayload["excludeTags"]; !ok {
777
+ t.Fatal("Firecrawl excludeTags setting disappeared")
778
+ }
779
+ }
780
+
781
+ func TestReadLinkGitHubPackageAuthFailureUsesExplicitBestEffortFirecrawl(t *testing.T) {
782
+ originalClient := http.DefaultClient
783
+ t.Cleanup(func() { http.DefaultClient = originalClient })
784
+ var firecrawlCalled bool
785
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
786
+ switch {
787
+ case req.URL.Host == "api.github.com" && req.URL.Path == "/orgs/acme/packages/container/widget":
788
+ return testHTTPResponse(req, http.StatusUnauthorized, `{"message":"Requires authentication"}`, map[string]string{"Content-Type": "application/json"}), nil
789
+ case req.Method == http.MethodPost && req.URL.String() == "https://api.firecrawl.dev/v2/scrape":
790
+ firecrawlCalled = true
791
+ body := `{"success":true,"data":{"metadata":{"title":"Package widget · GitHub"},"markdown":"# widget\n\nPublic Latest\n\n### Recent tagged image versions\n\n- v1\n\n$ docker pull ghcr.io/acme/widget:v1\n"}}`
792
+ return testHTTPResponse(req, http.StatusOK, body, map[string]string{"Content-Type": "application/json"}), nil
793
+ default:
794
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
795
+ }
796
+ })}
797
+ t.Setenv("GH_TOKEN", "")
798
+ t.Setenv("GITHUB_TOKEN", "")
799
+ t.Setenv("FIRECRAWL_API_KEY", "fake-firecrawl-key")
800
+
801
+ out, err := ReadLink("https://github.com/orgs/acme/packages/container/package/widget")
802
+ if err != nil {
803
+ t.Fatal(err)
804
+ }
805
+ if !firecrawlCalled {
806
+ t.Fatal("expected GitHub Package auth failure to use best-effort Firecrawl")
807
+ }
808
+ for _, want := range []string{"Best-effort GitHub Package page crawl", "may be incomplete", "Recent tagged image versions", "ghcr.io/acme/widget:v1"} {
809
+ if !strings.Contains(out, want) {
810
+ t.Fatalf("Package fallback output missing %q:\n%s", want, out)
811
+ }
812
+ }
813
+ }
814
+
815
+ func TestReadLinkGitHubPackageRateLimitDoesNotUseFirecrawl(t *testing.T) {
816
+ originalClient := http.DefaultClient
817
+ t.Cleanup(func() { http.DefaultClient = originalClient })
818
+ var firecrawlCalled bool
819
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
820
+ if req.URL.Host == "api.github.com" {
821
+ return testHTTPResponse(req, http.StatusForbidden, `{"message":"API rate limit exceeded"}`, map[string]string{
822
+ "X-RateLimit-Remaining": "0",
823
+ "X-RateLimit-Resource": "core",
824
+ "X-RateLimit-Reset": "1893456000",
825
+ }), nil
826
+ }
827
+ if req.URL.Host == "api.firecrawl.dev" {
828
+ firecrawlCalled = true
829
+ }
830
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
831
+ })}
832
+ t.Setenv("GH_TOKEN", "")
833
+ t.Setenv("GITHUB_TOKEN", "")
834
+ t.Setenv("FIRECRAWL_API_KEY", "fake-firecrawl-key")
835
+
836
+ _, err := ReadLink("https://github.com/orgs/acme/packages/container/package/widget")
837
+ if err == nil || !strings.Contains(err.Error(), "rate limit") {
838
+ t.Fatalf("expected native rate-limit error, got %v", err)
839
+ }
840
+ if firecrawlCalled {
841
+ t.Fatal("Package rate limit must remain authoritative instead of falling back to Firecrawl")
842
+ }
843
+ }
844
+
845
+ func TestReadLinkGitHubPackageWithoutFirecrawlKeyKeepsNativeAuthError(t *testing.T) {
846
+ originalClient := http.DefaultClient
847
+ t.Cleanup(func() { http.DefaultClient = originalClient })
848
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
849
+ if req.URL.Host == "api.github.com" {
850
+ return testHTTPResponse(req, http.StatusUnauthorized, `{"message":"Requires authentication"}`, nil), nil
851
+ }
852
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
853
+ })}
854
+ t.Setenv("GH_TOKEN", "")
855
+ t.Setenv("GITHUB_TOKEN", "")
856
+ t.Setenv("FIRECRAWL_API_KEY", "")
857
+
858
+ _, err := ReadLink("https://github.com/orgs/acme/packages/container/package/widget")
859
+ if err == nil || !strings.Contains(err.Error(), "authentication is required") || strings.Contains(err.Error(), "Firecrawl") {
860
+ t.Fatalf("expected unchanged native auth error without Firecrawl key, got %v", err)
861
+ }
862
+ }
863
+
864
+ func TestReadLinkNonPackageNativeAuthFailureDoesNotUseFirecrawl(t *testing.T) {
865
+ originalClient := http.DefaultClient
866
+ t.Cleanup(func() { http.DefaultClient = originalClient })
867
+ var firecrawlCalled bool
868
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
869
+ if req.URL.Host == "api.github.com" {
870
+ return testHTTPResponse(req, http.StatusUnauthorized, `{"message":"Requires authentication"}`, nil), nil
871
+ }
872
+ if req.URL.Host == "api.firecrawl.dev" {
873
+ firecrawlCalled = true
874
+ }
875
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
876
+ })}
877
+ t.Setenv("GH_TOKEN", "")
878
+ t.Setenv("GITHUB_TOKEN", "")
879
+ t.Setenv("FIRECRAWL_API_KEY", "fake-firecrawl-key")
880
+
881
+ _, err := ReadLink("https://github.com/o/r/issues/1")
882
+ if err == nil || !strings.Contains(err.Error(), "authentication is required") {
883
+ t.Fatalf("expected native Issue auth error, got %v", err)
884
+ }
885
+ if firecrawlCalled {
886
+ t.Fatal("non-Package native auth failures must not fall back to Firecrawl")
887
+ }
888
+ }