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