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,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
|
+
}
|