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,425 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "strings"
11
+ "sync/atomic"
12
+ "testing"
13
+ "unicode/utf8"
14
+ )
15
+
16
+ func TestParseDiscussionAndGistTargets(t *testing.T) {
17
+ for _, tt := range []struct {
18
+ raw string
19
+ kind GitHubTargetKind
20
+ number int
21
+ owner string
22
+ name string
23
+ tail string
24
+ fragment string
25
+ }{
26
+ {raw: "https://github.com/o/r/discussions", kind: GitHubTargetDiscussions, owner: "o"},
27
+ {raw: "https://github.com/o/r/discussions/42", kind: GitHubTargetDiscussion, number: 42, owner: "o"},
28
+ {raw: "https://github.com/o/r/discussions/42#discussioncomment-123", kind: GitHubTargetDiscussion, number: 42, owner: "o", fragment: "discussioncomment-123"},
29
+ {raw: "https://gist.github.com/alice/abcdef", kind: GitHubTargetGist, owner: "alice", name: "abcdef"},
30
+ {raw: "https://gist.github.com/aa5a315d61ae9438b18d#gistcomment-456", kind: GitHubTargetGist, name: "aa5a315d61ae9438b18d", fragment: "gistcomment-456"},
31
+ {raw: "https://gist.github.com/alice/abcdef/deadbeef", kind: GitHubTargetGist, owner: "alice", name: "abcdef", tail: "deadbeef"},
32
+ {raw: "https://gist.github.com/alice/abcdef#file-demo-md-L2-L4", kind: GitHubTargetGist, owner: "alice", name: "abcdef", fragment: "file-demo-md-L2-L4"},
33
+ {raw: "https://gist.github.com/alice/abcdef#gistcomment-456", kind: GitHubTargetGist, owner: "alice", name: "abcdef", fragment: "gistcomment-456"},
34
+ } {
35
+ target := parseGitHubTarget(tt.raw)
36
+ if target == nil || target.Kind != tt.kind || target.Number != tt.number || target.Owner != tt.owner || target.Name != tt.name || target.Fragment != tt.fragment {
37
+ t.Fatalf("target %s => %#v", tt.raw, target)
38
+ }
39
+ if tt.tail != "" && (len(target.Tail) != 1 || target.Tail[0] != tt.tail) {
40
+ t.Fatalf("gist revision tail lost: %#v", target)
41
+ }
42
+ }
43
+ for _, raw := range []string{
44
+ "https://github.com/o/r/discussions/nope",
45
+ "https://gist.github.com/alice",
46
+ "https://gist.github.com/alice/abcdef/rev/extra",
47
+ "https://gist.github.com/alice/abcdef/raw",
48
+ } {
49
+ if target := parseGitHubTarget(raw); target != nil {
50
+ t.Fatalf("unsupported Discussion/Gist route parsed: %s => %#v", raw, target)
51
+ }
52
+ }
53
+ }
54
+
55
+ func TestDiscussionsWithoutTokenFailBeforeGraphQL(t *testing.T) {
56
+ var calls int32
57
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58
+ atomic.AddInt32(&calls, 1)
59
+ }))
60
+ defer server.Close()
61
+ client := testGitHubClient(server.URL, server.URL, "")
62
+ for _, raw := range []string{"https://github.com/o/r/discussions", "https://github.com/o/r/discussions/42", "https://github.com/o/r/discussions/42#discussioncomment-123"} {
63
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget(raw), client)
64
+ if result.Outcome != GitHubNativeFailure || result.Err == nil || !strings.Contains(result.Err.Error(), "require authentication") || !strings.Contains(result.Err.Error(), "GH_TOKEN") {
65
+ t.Fatalf("no-token Discussion result incorrect: %#v", result)
66
+ }
67
+ }
68
+ if atomic.LoadInt32(&calls) != 0 {
69
+ t.Fatalf("no-token Discussions made provider calls")
70
+ }
71
+ }
72
+
73
+ func TestDiscussionListIsBoundedAndAuthenticated(t *testing.T) {
74
+ var calls int32
75
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76
+ atomic.AddInt32(&calls, 1)
77
+ if r.URL.Path != "/graphql" || r.Header.Get("Authorization") != "Bearer fake-token" {
78
+ t.Fatalf("GraphQL auth/path wrong: %s %q", r.URL.Path, r.Header.Get("Authorization"))
79
+ }
80
+ body, _ := io.ReadAll(r.Body)
81
+ if !strings.Contains(string(body), "discussions(first:30") {
82
+ t.Fatalf("Discussion list is not bounded: %s", body)
83
+ }
84
+ w.Header().Set("Content-Type", "application/json")
85
+ _, _ = io.WriteString(w, `{"data":{"repository":{"discussions":{"nodes":[{"number":7,"title":"Question","url":"https://github.com/o/r/discussions/7","createdAt":"2026-08-01T00:00:00Z","updatedAt":"2026-08-02T00:00:00Z","upvoteCount":3,"locked":false,"author":{"login":"alice"},"category":{"name":"Q&A"}}],"pageInfo":{"hasNextPage":true,"endCursor":"cursor"}}}}}`)
86
+ }))
87
+ defer server.Close()
88
+ out, err := readGitHubDiscussions(context.Background(), testGitHubClient(server.URL, server.URL, "fake-token"), parseGitHubTarget("https://github.com/o/r/discussions"))
89
+ if err != nil {
90
+ t.Fatal(err)
91
+ }
92
+ for _, want := range []string{"view: discussions", "returned: 1", "more_available: true", "[#7 Question](https://github.com/o/r/discussions/7)", "Q&A", "@alice", "3 upvotes", "first 30"} {
93
+ if !strings.Contains(out, want) {
94
+ t.Fatalf("Discussion list missing %q:\n%s", want, out)
95
+ }
96
+ }
97
+ if atomic.LoadInt32(&calls) != 1 {
98
+ t.Fatalf("expected one bounded list GraphQL call")
99
+ }
100
+ }
101
+
102
+ func TestDiscussionDetailOverviewIsBoundedWithoutDeepPagination(t *testing.T) {
103
+ var calls int32
104
+ longBody := strings.Repeat("discussion body line\n", 500) + "<!-- hidden marker -->tail"
105
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
106
+ atomic.AddInt32(&calls, 1)
107
+ if r.URL.Path != "/graphql" || r.Header.Get("Authorization") != "Bearer fake-token" {
108
+ t.Fatalf("GraphQL auth/path wrong")
109
+ }
110
+ var request struct {
111
+ Query string `json:"query"`
112
+ }
113
+ _ = json.NewDecoder(r.Body).Decode(&request)
114
+ if !strings.Contains(request.Query, "comments(first:30") || !strings.Contains(request.Query, "replies(first:5") || strings.Contains(request.Query, "after:$after") {
115
+ t.Fatalf("Discussion overview query is not bounded: %s", request.Query)
116
+ }
117
+ w.Header().Set("Content-Type", "application/json")
118
+ _, _ = io.WriteString(w, fmt.Sprintf(`{"data":{"repository":{"discussion":{"number":42,"title":"How?","body":%q,"url":"https://github.com/o/r/discussions/42","createdAt":"2026-08-01T00:00:00Z","updatedAt":"2026-08-03T00:00:00Z","upvoteCount":5,"locked":false,"author":{"login":"owner"},"category":{"name":"Q&A"},"answer":{"id":"C101","databaseId":101,"body":"Answer body","url":"https://github.com/o/r/discussions/42#discussioncomment-101","createdAt":"2026-08-02T00:00:00Z","upvoteCount":2,"author":{"login":"bob"}},"comments":{"totalCount":250,"nodes":[{"id":"C101","databaseId":101,"body":"Answer body","url":"https://github.com/o/r/discussions/42#discussioncomment-101","createdAt":"2026-08-02T00:00:00Z","upvoteCount":2,"author":{"login":"bob"},"replies":{"totalCount":80,"nodes":[{"id":"R201","databaseId":201,"body":"First reply","url":"https://github.com/o/r/discussions/42#discussioncomment-201","createdAt":"2026-08-02T12:00:00Z","author":{"login":"alice"}}],"pageInfo":{"hasNextPage":true,"endCursor":"r1"}}},{"id":"C102","databaseId":102,"body":"Another comment","url":"https://github.com/o/r/discussions/42#discussioncomment-102","createdAt":"2026-08-04T00:00:00Z","author":{"login":"dave"},"replies":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}}}],"pageInfo":{"hasNextPage":true,"endCursor":"c2"}}}}}}`, longBody))
119
+ }))
120
+ defer server.Close()
121
+ out, err := readGitHubDiscussion(context.Background(), testGitHubClient(server.URL, server.URL, "fake-token"), parseGitHubTarget("https://github.com/o/r/discussions/42"))
122
+ if err != nil {
123
+ t.Fatal(err)
124
+ }
125
+ if got := utf8.RuneCountInString(out); got > githubOverviewRunes {
126
+ t.Fatalf("Discussion overview exceeded shared target: %d runes\n%s", got, out)
127
+ }
128
+ for _, want := range []string{
129
+ "overview: true", "comments_reported: 250", "comments_returned: 2", "comments_provider_more_available: true",
130
+ "accepted_answer_comment_id: 101", "accepted_answer_url: \"https://github.com/o/r/discussions/42#discussioncomment-101\"",
131
+ "### Accepted answer `101` by @bob", "#### Reply `201` by @alice", "More replies to comment `101` exist upstream",
132
+ "Discussion body preview locally truncated", "More top-level Discussion comments exist upstream",
133
+ } {
134
+ if !strings.Contains(out, want) {
135
+ t.Fatalf("Discussion overview missing %q:\n%s", want, out)
136
+ }
137
+ }
138
+ if strings.Contains(out, "hidden marker") || strings.Count(out, "Answer body") != 1 || atomic.LoadInt32(&calls) != 1 {
139
+ t.Fatalf("Discussion root sanitized/pagination wrong calls=%d:\n%s", calls, out)
140
+ }
141
+ }
142
+
143
+ func TestDiscussionExactCommentSelectorFindsDeferredReply(t *testing.T) {
144
+ var detailCalls, replyCalls int32
145
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146
+ if r.URL.Path != "/graphql" {
147
+ t.Fatalf("GraphQL path wrong")
148
+ }
149
+ var request struct {
150
+ Query string `json:"query"`
151
+ Variables map[string]any `json:"variables"`
152
+ }
153
+ _ = json.NewDecoder(r.Body).Decode(&request)
154
+ w.Header().Set("Content-Type", "application/json")
155
+ if strings.Contains(request.Query, "node(id:$id)") {
156
+ atomic.AddInt32(&replyCalls, 1)
157
+ if request.Variables["id"] != "C101" || request.Variables["after"] != "r1" {
158
+ t.Fatalf("unexpected deferred reply vars: %#v", request.Variables)
159
+ }
160
+ _, _ = io.WriteString(w, `{"data":{"node":{"replies":{"nodes":[{"id":"R202","databaseId":202,"body":"Selected reply <!-- hidden --> kept in full","url":"https://github.com/o/r/discussions/42#discussioncomment-202","createdAt":"2026-08-03T00:00:00Z","updatedAt":"2026-08-03T01:00:00Z","author":{"login":"carol"}}],"pageInfo":{"hasNextPage":false}}}}}`)
161
+ return
162
+ }
163
+ atomic.AddInt32(&detailCalls, 1)
164
+ _, _ = io.WriteString(w, `{"data":{"repository":{"discussion":{"number":42,"title":"How?","url":"https://github.com/o/r/discussions/42","author":{"login":"owner"},"category":{"name":"Q&A"},"comments":{"totalCount":1,"nodes":[{"id":"C101","databaseId":101,"body":"Parent","url":"https://github.com/o/r/discussions/42#discussioncomment-101","author":{"login":"bob"},"replies":{"totalCount":2,"nodes":[{"id":"R201","databaseId":201,"body":"Not selected","url":"https://github.com/o/r/discussions/42#discussioncomment-201","author":{"login":"alice"}}],"pageInfo":{"hasNextPage":true,"endCursor":"r1"}}}],"pageInfo":{"hasNextPage":false}}}}}}`)
165
+ }))
166
+ defer server.Close()
167
+ out, err := readGitHubDiscussion(context.Background(), testGitHubClient(server.URL, server.URL, "fake-token"), parseGitHubTarget("https://github.com/o/r/discussions/42#discussioncomment-202"))
168
+ if err != nil {
169
+ t.Fatal(err)
170
+ }
171
+ for _, want := range []string{"selector: \"discussioncomment-202\"", "comment_id: 202", "parent_comment_id: 101", "url: \"https://github.com/o/r/discussions/42#discussioncomment-202\"", "Selected reply", "kept in full", "From: [#42 How?]"} {
172
+ if !strings.Contains(out, want) {
173
+ t.Fatalf("exact Discussion reply missing %q:\n%s", want, out)
174
+ }
175
+ }
176
+ if strings.Contains(out, "hidden") || strings.Contains(out, "Not selected") || atomic.LoadInt32(&detailCalls) != 1 || atomic.LoadInt32(&replyCalls) != 1 {
177
+ t.Fatalf("exact Discussion lookup fan-out/content wrong detail=%d replies=%d:\n%s", detailCalls, replyCalls, out)
178
+ }
179
+ }
180
+
181
+ func TestDiscussionExactTopLevelCommentStopsBeforeReplyPagination(t *testing.T) {
182
+ var calls int32
183
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
184
+ atomic.AddInt32(&calls, 1)
185
+ var request struct {
186
+ Query string `json:"query"`
187
+ }
188
+ _ = json.NewDecoder(r.Body).Decode(&request)
189
+ if strings.Contains(request.Query, "node(id:$id)") {
190
+ t.Fatal("top-level exact comment must not paginate replies")
191
+ }
192
+ w.Header().Set("Content-Type", "application/json")
193
+ _, _ = io.WriteString(w, `{"data":{"repository":{"discussion":{"number":42,"title":"How?","url":"https://github.com/o/r/discussions/42","comments":{"totalCount":1,"nodes":[{"id":"C101","databaseId":101,"body":"Exact top-level","url":"https://github.com/o/r/discussions/42#discussioncomment-101","author":{"login":"bob"},"replies":{"totalCount":500,"nodes":[],"pageInfo":{"hasNextPage":true,"endCursor":"r0"}}}],"pageInfo":{"hasNextPage":false}}}}}}`)
194
+ }))
195
+ defer server.Close()
196
+ out, err := readGitHubDiscussion(context.Background(), testGitHubClient(server.URL, server.URL, "fake-token"), parseGitHubTarget("https://github.com/o/r/discussions/42#discussioncomment-101"))
197
+ if err != nil {
198
+ t.Fatal(err)
199
+ }
200
+ if !strings.Contains(out, "Exact top-level") || atomic.LoadInt32(&calls) != 1 {
201
+ t.Fatalf("top-level exact comment did not stop early calls=%d:\n%s", calls, out)
202
+ }
203
+ }
204
+
205
+ func TestGistSelectorSlugAndLines(t *testing.T) {
206
+ if got := gistFileSlug("Hello.World_test.md"); got != "file-hello-world-test-md" {
207
+ t.Fatalf("gist slug=%q", got)
208
+ }
209
+ selector, ok, err := parseGistFileSelector("file-hello-world-test-md-L2-L3")
210
+ if err != nil || !ok || selector.Slug != "file-hello-world-test-md" || selector.Start != 2 || selector.End != 3 {
211
+ t.Fatalf("selector=%#v ok=%v err=%v", selector, ok, err)
212
+ }
213
+ if _, _, err := parseGistFileSelector("file-a-L4-L2"); err == nil {
214
+ t.Fatal("reversed Gist line range should fail")
215
+ }
216
+ id, ok, err := parseGistCommentSelector("gistcomment-3793344")
217
+ if err != nil || !ok || id != 3793344 {
218
+ t.Fatalf("gist comment selector id=%d ok=%v err=%v", id, ok, err)
219
+ }
220
+ }
221
+
222
+ func TestGistRootIsBoundedAndDoesNotFollowCommentPagination(t *testing.T) {
223
+ var commentPages int32
224
+ var server *httptest.Server
225
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
226
+ w.Header().Set("Content-Type", "application/json")
227
+ switch r.URL.Path {
228
+ case "/gists/abc":
229
+ _, _ = io.WriteString(w, `{"id":"abc","description":"demo","public":true,"html_url":"https://gist.github.com/alice/abc","created_at":"2026-08-01T00:00:00Z","updated_at":"2026-08-02T00:00:00Z","comments":2,"owner":{"login":"alice"},"files":{"demo.md":{"filename":"demo.md","type":"text/markdown","language":"Markdown","raw_url":"https://gist.githubusercontent.test/raw/demo","size":40,"truncated":false,"content":"# Demo\n<!-- source marker -->\nkept\nline4"},"z.txt":{"filename":"z.txt","type":"text/plain","raw_url":"https://gist.githubusercontent.test/raw/z","size":1,"content":"z"}},"history":[{"version":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","committed_at":"2026-08-02T00:00:00Z","change_status":{"total":2,"additions":1,"deletions":1}}]}`)
230
+ case "/gists/abc/comments":
231
+ atomic.AddInt32(&commentPages, 1)
232
+ if r.URL.Query().Get("page") != "" || r.URL.Query().Get("per_page") != "30" {
233
+ t.Fatalf("Gist root followed/changed comment pagination: %s", r.URL.RawQuery)
234
+ }
235
+ w.Header().Set("Link", fmt.Sprintf(`<%s/gists/abc/comments?per_page=30&page=2>; rel="next"`, server.URL))
236
+ _, _ = io.WriteString(w, `[{"id":1,"body":"Visible comment <!-- hidden --> tail","user":{"login":"bob"},"created_at":"2026-08-03T00:00:00Z","url":"https://api.github.com/gists/abc/comments/1"}]`)
237
+ default:
238
+ t.Fatalf("unexpected Gist request %s", r.URL.Path)
239
+ }
240
+ }))
241
+ defer server.Close()
242
+ out, err := readGitHubGist(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://gist.github.com/alice/abc"))
243
+ if err != nil {
244
+ t.Fatal(err)
245
+ }
246
+ for _, want := range []string{"overview: true", "## File index", "[demo.md](https://gist.github.com/alice/abc#file-demo-md)", "<!-- source marker -->", "## Comment index", "[Comment 1](https://gist.github.com/alice/abc#gistcomment-1)", "Visible comment", "tail", "comments_provider_more_available: true", "## Revision index", "`aaaaaaaaaaaa`"} {
247
+ if !strings.Contains(out, want) {
248
+ t.Fatalf("Gist root missing %q:\n%s", want, out)
249
+ }
250
+ }
251
+ if strings.Contains(out, "<!-- hidden -->") || strings.Contains(out, "## demo.md") || atomic.LoadInt32(&commentPages) != 1 {
252
+ t.Fatalf("Gist root sanitization/pagination wrong pages=%d:\n%s", commentPages, out)
253
+ }
254
+ }
255
+
256
+ func TestLargeGistRootStaysBoundedWithoutRawFanout(t *testing.T) {
257
+ gist := githubGist{ID: "big", Public: true, HTMLURL: "https://gist.github.com/alice/big", Comments: 500, Files: map[string]githubGistFile{}}
258
+ gist.Owner.Login = "alice"
259
+ for i := 0; i < 100; i++ {
260
+ gist.Files[fmt.Sprintf("file-%03d.txt", i)] = githubGistFile{Filename: fmt.Sprintf("file-%03d.txt", i), RawURL: fmt.Sprintf("RAW-%03d", i), Size: 10000, Content: strings.Repeat(fmt.Sprintf("content-%03d ", i), 300), Truncated: i%3 == 0}
261
+ }
262
+ gist.History = make([]githubGistHistory, 100)
263
+ for i := range gist.History {
264
+ gist.History[i].Version = fmt.Sprintf("%040x", i+1)
265
+ gist.History[i].CommittedAt = "2026-08-01T00:00:00Z"
266
+ }
267
+ comments := make([]githubGistComment, 30)
268
+ for i := range comments {
269
+ body := strings.Repeat(fmt.Sprintf("comment-%02d ", i), 100)
270
+ comments[i] = githubGistComment{ID: int64(i + 1), Body: &body, CreatedAt: "2026-08-01T00:00:00Z"}
271
+ comments[i].User.Login = "user"
272
+ }
273
+ var commentCalls, rawCalls int32
274
+ var server *httptest.Server
275
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
276
+ w.Header().Set("Content-Type", "application/json")
277
+ switch {
278
+ case r.URL.Path == "/gists/big":
279
+ _ = json.NewEncoder(w).Encode(gist)
280
+ case r.URL.Path == "/gists/big/comments":
281
+ atomic.AddInt32(&commentCalls, 1)
282
+ w.Header().Set("Link", fmt.Sprintf(`<%s/gists/big/comments?per_page=30&page=2>; rel="next"`, server.URL))
283
+ _ = json.NewEncoder(w).Encode(comments)
284
+ default:
285
+ atomic.AddInt32(&rawCalls, 1)
286
+ t.Fatalf("Gist root unexpectedly fetched subordinate raw URL %s", r.URL.Path)
287
+ }
288
+ }))
289
+ defer server.Close()
290
+ out, err := readGitHubGist(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://gist.github.com/alice/big"))
291
+ if err != nil {
292
+ t.Fatal(err)
293
+ }
294
+ if got := utf8.RuneCountInString(out); got > githubOverviewRunes {
295
+ t.Fatalf("large Gist overview exceeded shared target: %d runes\n%s", got, out)
296
+ }
297
+ for _, want := range []string{"files_returned: 100", "files_local_omitted:", "comments_reported: 500", "comments_returned: 30", "comments_local_omitted:", "revisions_returned: 100", "locally omitted from this overview", "comments_provider_more_available: true"} {
298
+ if !strings.Contains(out, want) {
299
+ t.Fatalf("large Gist overview missing %q:\n%s", want, out)
300
+ }
301
+ }
302
+ if strings.Contains(out, "files_local_omitted: 0") || strings.Contains(out, "comments_local_omitted: 0") || strings.Contains(out, "revisions_local_omitted: 0") || atomic.LoadInt32(&commentCalls) != 1 || atomic.LoadInt32(&rawCalls) != 0 {
303
+ t.Fatalf("large Gist root fan-out/omission wrong comments=%d raw=%d", commentCalls, rawCalls)
304
+ }
305
+ }
306
+
307
+ func TestGistFileLineSelectorNarrowsBeforeComments(t *testing.T) {
308
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
309
+ w.Header().Set("Content-Type", "application/json")
310
+ if r.URL.Path == "/gists/abc" {
311
+ _, _ = io.WriteString(w, `{"id":"abc","public":true,"html_url":"https://gist.github.com/alice/abc","files":{"demo.md":{"filename":"demo.md","content":"one\ntwo\nthree\nfour"},"other.txt":{"filename":"other.txt","content":"other"}}}`)
312
+ return
313
+ }
314
+ t.Fatalf("exact Gist file selector must not fetch comments/other resources: %s", r.URL.Path)
315
+ }))
316
+ defer server.Close()
317
+ out, err := readGitHubGist(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://gist.github.com/alice/abc#file-demo-md-L2-L3"))
318
+ if err != nil {
319
+ t.Fatal(err)
320
+ }
321
+ if !strings.Contains(out, "# demo.md") || !strings.Contains(out, "Lines 2-3") || !strings.Contains(out, "two\nthree") || !strings.Contains(out, `url: "https://gist.github.com/alice/abc#file-demo-md-L2-L3"`) || strings.Contains(out, "other") || strings.Contains(out, "## Comment") {
322
+ t.Fatalf("Gist selector failed to narrow:\n%s", out)
323
+ }
324
+ }
325
+
326
+ func TestGistTruncatedFileSelectorUsesRawWithoutForwardingToken(t *testing.T) {
327
+ var rawAuth string
328
+ raw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
329
+ rawAuth = r.Header.Get("Authorization")
330
+ _, _ = io.WriteString(w, "FULL RAW\n<!-- source preserved -->\n")
331
+ }))
332
+ defer raw.Close()
333
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
334
+ w.Header().Set("Content-Type", "application/json")
335
+ if r.URL.Path == "/gists/abc" {
336
+ _, _ = io.WriteString(w, fmt.Sprintf(`{"id":"abc","files":{"big.txt":{"filename":"big.txt","raw_url":%q,"size":999,"truncated":true,"content":"PARTIAL"}}}`, raw.URL))
337
+ return
338
+ }
339
+ t.Fatalf("unexpected exact Gist file request %s", r.URL.Path)
340
+ }))
341
+ defer api.Close()
342
+ out, err := readGitHubGist(context.Background(), testGitHubClient(api.URL, api.URL, "fake-token"), parseGitHubTarget("https://gist.github.com/alice/abc#file-big-txt"))
343
+ if err != nil {
344
+ t.Fatal(err)
345
+ }
346
+ if !strings.Contains(out, "FULL RAW") || strings.Contains(out, "PARTIAL") || !strings.Contains(out, "<!-- source preserved -->") || rawAuth != "" {
347
+ t.Fatalf("Gist raw fallback/token safety wrong auth=%q:\n%s", rawAuth, out)
348
+ }
349
+ }
350
+
351
+ func TestGistTruncatedRawFailurePointsToRawURL(t *testing.T) {
352
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
353
+ w.Header().Set("Content-Type", "application/json")
354
+ if r.URL.Path == "/gists/abc" {
355
+ _, _ = io.WriteString(w, `{"id":"abc","files":{"big.txt":{"filename":"big.txt","raw_url":"http://127.0.0.1:1/unavailable","truncated":true,"content":"PARTIAL"}}}`)
356
+ return
357
+ }
358
+ t.Fatalf("unexpected exact Gist file request %s", r.URL.Path)
359
+ }))
360
+ defer api.Close()
361
+ out, err := readGitHubGist(context.Background(), testGitHubClient(api.URL, api.URL, ""), parseGitHubTarget("https://gist.github.com/alice/abc#file-big-txt"))
362
+ if err != nil {
363
+ t.Fatal(err)
364
+ }
365
+ if strings.Contains(out, "PARTIAL") || !strings.Contains(out, "marked this API file content truncated") || !strings.Contains(out, "Full raw file: http://127.0.0.1:1/unavailable") {
366
+ t.Fatalf("truncated raw failure not truthful:\n%s", out)
367
+ }
368
+ }
369
+
370
+ func TestGistExactCommentUsesSingleCommentEndpointAndCanonicalFragment(t *testing.T) {
371
+ var listCalls, exactCalls int32
372
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
373
+ w.Header().Set("Content-Type", "application/json")
374
+ switch r.URL.Path {
375
+ case "/gists/abc":
376
+ _, _ = io.WriteString(w, `{"id":"abc","html_url":"https://gist.github.com/alice/abc","files":{"a.txt":{"filename":"a.txt","content":"DO NOT RENDER"}}}`)
377
+ case "/gists/abc/comments/3793344":
378
+ atomic.AddInt32(&exactCalls, 1)
379
+ _, _ = io.WriteString(w, `{"id":3793344,"url":"https://api.github.com/gists/abc/comments/3793344","body":"Exact Gist comment <!-- hidden --> kept","user":{"login":"bob"},"created_at":"2026-08-01T00:00:00Z"}`)
380
+ case "/gists/abc/comments":
381
+ atomic.AddInt32(&listCalls, 1)
382
+ t.Fatal("exact Gist comment must not list comments")
383
+ default:
384
+ t.Fatalf("unexpected Gist exact-comment request %s", r.URL.Path)
385
+ }
386
+ }))
387
+ defer server.Close()
388
+ out, err := readGitHubGist(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://gist.github.com/alice/abc#gistcomment-3793344"))
389
+ if err != nil {
390
+ t.Fatal(err)
391
+ }
392
+ for _, want := range []string{"selector: \"gistcomment-3793344\"", "comment_id: 3793344", `url: "https://gist.github.com/alice/abc#gistcomment-3793344"`, "@bob", "Exact Gist comment", "kept"} {
393
+ if !strings.Contains(out, want) {
394
+ t.Fatalf("exact Gist comment missing %q:\n%s", want, out)
395
+ }
396
+ }
397
+ if strings.Contains(out, "hidden") || strings.Contains(out, "DO NOT RENDER") || atomic.LoadInt32(&exactCalls) != 1 || atomic.LoadInt32(&listCalls) != 0 {
398
+ t.Fatalf("exact Gist comment endpoint/content wrong exact=%d list=%d:\n%s", exactCalls, listCalls, out)
399
+ }
400
+ }
401
+
402
+ func TestGistRevisionUsesRevisionEndpoint(t *testing.T) {
403
+ var gistPath string
404
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
405
+ w.Header().Set("Content-Type", "application/json")
406
+ if strings.HasPrefix(r.URL.Path, "/gists/abc/") && !strings.HasSuffix(r.URL.Path, "/comments") {
407
+ gistPath = r.URL.Path
408
+ _, _ = io.WriteString(w, `{"id":"abc","files":{"a.txt":{"filename":"a.txt","content":"revision"}}}`)
409
+ return
410
+ }
411
+ if r.URL.Path == "/gists/abc/comments" {
412
+ _, _ = io.WriteString(w, `[]`)
413
+ return
414
+ }
415
+ t.Fatalf("unexpected Gist revision request %s", r.URL.Path)
416
+ }))
417
+ defer server.Close()
418
+ out, err := readGitHubGist(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://gist.github.com/alice/abc/deadbeef"))
419
+ if err != nil {
420
+ t.Fatal(err)
421
+ }
422
+ if gistPath != "/gists/abc/deadbeef" || !strings.Contains(out, `revision: "deadbeef"`) || !strings.Contains(out, "revision") {
423
+ t.Fatalf("Gist revision endpoint/output wrong path=%s:\n%s", gistPath, out)
424
+ }
425
+ }