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,731 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/http/httptest"
|
|
10
|
+
"net/url"
|
|
11
|
+
"strings"
|
|
12
|
+
"sync/atomic"
|
|
13
|
+
"testing"
|
|
14
|
+
"unicode/utf8"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func TestParseGitHubIssueTargets(t *testing.T) {
|
|
18
|
+
tests := []struct {
|
|
19
|
+
rawURL string
|
|
20
|
+
kind GitHubTargetKind
|
|
21
|
+
number int
|
|
22
|
+
name string
|
|
23
|
+
fragment string
|
|
24
|
+
}{
|
|
25
|
+
{rawURL: "https://github.com/o/r/issues", kind: GitHubTargetIssueList},
|
|
26
|
+
{rawURL: "https://github.com/o/r/issues?q=is%3Aissue+state%3Aclosed", kind: GitHubTargetIssueList},
|
|
27
|
+
{rawURL: "https://github.com/o/r/issues/42", kind: GitHubTargetIssue, number: 42},
|
|
28
|
+
{rawURL: "https://github.com/o/r/issues/42#issue-987", kind: GitHubTargetIssue, number: 42, fragment: "issue-987"},
|
|
29
|
+
{rawURL: "https://github.com/o/r/issues/42#issuecomment-123", kind: GitHubTargetIssue, number: 42, fragment: "issuecomment-123"},
|
|
30
|
+
{rawURL: "https://github.com/o/r/labels", kind: GitHubTargetLabelList},
|
|
31
|
+
{rawURL: "https://github.com/o/r/labels/good%20first%20issue", kind: GitHubTargetLabel, name: "good first issue"},
|
|
32
|
+
{rawURL: "https://github.com/o/r/milestones", kind: GitHubTargetMilestones},
|
|
33
|
+
{rawURL: "https://github.com/o/r/milestone/7", kind: GitHubTargetMilestone, number: 7},
|
|
34
|
+
}
|
|
35
|
+
for _, tt := range tests {
|
|
36
|
+
t.Run(tt.rawURL, func(t *testing.T) {
|
|
37
|
+
target := parseGitHubTarget(tt.rawURL)
|
|
38
|
+
if target == nil {
|
|
39
|
+
t.Fatal("expected native target")
|
|
40
|
+
}
|
|
41
|
+
if target.Kind != tt.kind || target.Number != tt.number || target.Name != tt.name || target.Fragment != tt.fragment {
|
|
42
|
+
t.Fatalf("unexpected target: %#v", target)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for _, rawURL := range []string{
|
|
48
|
+
"https://github.com/o/r/issues/not-a-number",
|
|
49
|
+
"https://github.com/o/r/issues/1/extra",
|
|
50
|
+
"https://github.com/o/r/milestone/not-a-number",
|
|
51
|
+
"https://github.com/o/r/security/dependabot",
|
|
52
|
+
} {
|
|
53
|
+
if target := parseGitHubTarget(rawURL); target != nil {
|
|
54
|
+
t.Fatalf("expected %s to remain unsupported, got %#v", rawURL, target)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func TestReadGitHubIssueStopsTimelinePaginationWhenProviderHasMore(t *testing.T) {
|
|
60
|
+
var timelinePages int32
|
|
61
|
+
var subIssuePages int32
|
|
62
|
+
var server *httptest.Server
|
|
63
|
+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
64
|
+
w.Header().Set("Content-Type", "application/json")
|
|
65
|
+
switch r.URL.Path {
|
|
66
|
+
case "/repos/o/r/issues/42":
|
|
67
|
+
_, _ = io.WriteString(w, `{
|
|
68
|
+
"number":42,"state":"closed","state_reason":"completed","title":"A real issue",
|
|
69
|
+
"body":"Visible body\n<!-- automation marker -->\nStill visible","html_url":"https://github.com/o/r/issues/42",
|
|
70
|
+
"user":{"login":"alice"},"author_association":"MEMBER",
|
|
71
|
+
"labels":[{"name":"bug"},{"name":"priority/high"}],"assignees":[{"login":"bob"}],
|
|
72
|
+
"milestone":{"number":7,"title":"v2","state":"open"},"locked":true,"active_lock_reason":"too heated",
|
|
73
|
+
"comments":2,"created_at":"2026-08-01T00:00:00Z","updated_at":"2026-08-02T00:00:00Z","closed_at":"2026-08-03T00:00:00Z",
|
|
74
|
+
"type":{"name":"Bug"},
|
|
75
|
+
"pinned_comment":{"id":2,"body":"Pinned answer","html_url":"https://github.com/o/r/issues/42#issuecomment-2","user":{"login":"alice"},"created_at":"2026-08-02T12:00:00Z"}
|
|
76
|
+
}`)
|
|
77
|
+
case "/repos/o/r/issues/42/timeline":
|
|
78
|
+
atomic.AddInt32(&timelinePages, 1)
|
|
79
|
+
if r.URL.Query().Get("page") == "2" {
|
|
80
|
+
_, _ = io.WriteString(w, `[
|
|
81
|
+
{"id":2,"event":"commented","body":"Pinned answer","html_url":"https://github.com/o/r/issues/42#issuecomment-2","user":{"login":"alice"},"created_at":"2026-08-02T12:00:00Z"},
|
|
82
|
+
{"event":"cross-referenced","actor":{"login":"carol"},"created_at":"2026-08-02T13:00:00Z","source":{"issue":{"number":55,"title":"Related","html_url":"https://github.com/o/r/issues/55"}}},
|
|
83
|
+
{"event":"renamed","actor":{"login":"alice"},"created_at":"2026-08-02T14:00:00Z","rename":{"from":"Old","to":"A real issue"}},
|
|
84
|
+
{"event":"subscribed","actor":{"login":"noise"},"created_at":"2026-08-02T15:00:00Z"}
|
|
85
|
+
]`)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/issues/42/timeline?per_page=100&page=2>; rel="next"`, server.URL))
|
|
89
|
+
_, _ = io.WriteString(w, `[
|
|
90
|
+
{"id":1,"event":"commented","body":"Bot content\n<!-- hidden -->\nkept","html_url":"https://github.com/o/r/issues/42#issuecomment-1","user":{"login":"build-bot","type":"Bot"},"author_association":"NONE","created_at":"2026-08-02T10:00:00Z"},
|
|
91
|
+
{"event":"labeled","actor":{"login":"alice"},"created_at":"2026-08-02T11:00:00Z","label":{"name":"bug"}}
|
|
92
|
+
]`)
|
|
93
|
+
case "/repos/o/r/issues/42/parent":
|
|
94
|
+
_, _ = io.WriteString(w, `{"number":10,"title":"Parent","html_url":"https://github.com/o/r/issues/10"}`)
|
|
95
|
+
case "/repos/o/r/issues/42/sub_issues":
|
|
96
|
+
atomic.AddInt32(&subIssuePages, 1)
|
|
97
|
+
if r.URL.Query().Get("page") != "" {
|
|
98
|
+
t.Fatalf("Issue root followed relationship pagination: %s", r.URL.RawQuery)
|
|
99
|
+
}
|
|
100
|
+
w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/issues/42/sub_issues?per_page=100&page=2>; rel="next"`, server.URL))
|
|
101
|
+
_, _ = io.WriteString(w, `[{"number":43,"title":"Child","html_url":"https://github.com/o/r/issues/43"}]`)
|
|
102
|
+
case "/repos/o/r/issues/42/dependencies/blocked_by":
|
|
103
|
+
_, _ = io.WriteString(w, `[{"number":41,"title":"Blocker","html_url":"https://github.com/o/r/issues/41"}]`)
|
|
104
|
+
case "/repos/o/r/issues/42/dependencies/blocking":
|
|
105
|
+
_, _ = io.WriteString(w, `[{"number":50,"title":"Downstream","html_url":"https://github.com/o/r/issues/50"}]`)
|
|
106
|
+
case "/repos/o/r/issues/42/issue-field-values":
|
|
107
|
+
_, _ = io.WriteString(w, `[
|
|
108
|
+
{"issue_field_name":"Priority","data_type":"single_select","value":1,"single_select_option":{"name":"High"}},
|
|
109
|
+
{"issue_field_name":"Points","data_type":"number","value":8}
|
|
110
|
+
]`)
|
|
111
|
+
default:
|
|
112
|
+
t.Fatalf("unexpected request: %s?%s", r.URL.Path, r.URL.RawQuery)
|
|
113
|
+
}
|
|
114
|
+
}))
|
|
115
|
+
defer server.Close()
|
|
116
|
+
|
|
117
|
+
target := parseGitHubTarget("https://github.com/o/r/issues/42")
|
|
118
|
+
out, err := readGitHubIssue(context.Background(), testGitHubClient(server.URL, server.URL, ""), target)
|
|
119
|
+
if err != nil {
|
|
120
|
+
t.Fatal(err)
|
|
121
|
+
}
|
|
122
|
+
for _, want := range []string{
|
|
123
|
+
`state: "closed"`, `state_reason: "completed"`, `locked: true`, `lock_reason: "too heated"`,
|
|
124
|
+
`labels: ["bug","priority/high"]`, `assignees: ["@bob"]`, `milestone: "v2"`, `type: "Bug"`,
|
|
125
|
+
"overview: true", "timeline_provider_more_available: true", "relationships_provider_more_available: true", "comments_provider_complete: false",
|
|
126
|
+
"Visible body", "Still visible", "Comment `1` by @build-bot", "Bot content", "kept", "pinned", "Pinned answer",
|
|
127
|
+
"Parent: [#10 Parent]", "Sub-issue: [#43 Child]", "Blocked by: [#41 Blocker]", "Blocking: [#50 Downstream]",
|
|
128
|
+
"Priority: High", "Points: 8", "More timeline pages may exist upstream", "More Issue relationship entries exist upstream",
|
|
129
|
+
} {
|
|
130
|
+
if !strings.Contains(out, want) {
|
|
131
|
+
t.Fatalf("output missing %q:\n%s", want, out)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if strings.Contains(out, "automation marker") || strings.Contains(out, "<!-- hidden -->") || strings.Contains(out, "@noise") {
|
|
135
|
+
t.Fatalf("human-view sanitization/noise filtering regressed:\n%s", out)
|
|
136
|
+
}
|
|
137
|
+
if strings.Count(out, "Pinned answer") != 1 {
|
|
138
|
+
t.Fatalf("pinned comment duplicated instead of being annotated in timeline:\n%s", out)
|
|
139
|
+
}
|
|
140
|
+
if strings.Contains(out, "cross-referenced [#55 Related]") || strings.Contains(out, "renamed the Issue") {
|
|
141
|
+
t.Fatalf("Issue root followed later timeline pages despite bounded overview contract:\n%s", out)
|
|
142
|
+
}
|
|
143
|
+
if got := atomic.LoadInt32(&timelinePages); got != 1 {
|
|
144
|
+
t.Fatalf("expected one bounded timeline page, got %d", got)
|
|
145
|
+
}
|
|
146
|
+
if got := atomic.LoadInt32(&subIssuePages); got != 1 {
|
|
147
|
+
t.Fatalf("expected one bounded relationship page, got %d", got)
|
|
148
|
+
}
|
|
149
|
+
if strings.Contains(out, "GH_TOKEN") {
|
|
150
|
+
t.Fatalf("successful anonymous Issue read nagged for auth:\n%s", out)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func TestIssueTimelineMinimizedShapeVariants(t *testing.T) {
|
|
155
|
+
var events []githubTimelineEvent
|
|
156
|
+
payload := `[
|
|
157
|
+
{"id":1,"event":"commented","minimized":false,"body":"visible"},
|
|
158
|
+
{"id":2,"event":"commented","minimized":null,"body":"visible"},
|
|
159
|
+
{"id":3,"event":"commented","minimized":true,"minimized_reason":"outdated","body":"hidden"},
|
|
160
|
+
{"id":4,"event":"commented","minimized":{"reason":"spam"},"minimized_reason":null,"body":"hidden"},
|
|
161
|
+
{"id":5,"event":"commented","minimized":{"reason":{"future":"shape"}},"body":"hidden"},
|
|
162
|
+
{"id":6,"event":"commented","minimized":"future-shape","body":"visible"}
|
|
163
|
+
]`
|
|
164
|
+
if err := json.Unmarshal([]byte(payload), &events); err != nil {
|
|
165
|
+
t.Fatalf("polymorphic minimized data must not erase the timeline: %v", err)
|
|
166
|
+
}
|
|
167
|
+
if len(events) != 6 {
|
|
168
|
+
t.Fatalf("unexpected event count: %d", len(events))
|
|
169
|
+
}
|
|
170
|
+
if events[0].Minimized || events[1].Minimized {
|
|
171
|
+
t.Fatal("false/null minimized states should remain visible")
|
|
172
|
+
}
|
|
173
|
+
if !events[2].Minimized || events[2].MinimizedReason != "outdated" {
|
|
174
|
+
t.Fatalf("boolean minimized state lost reason: %#v", events[2])
|
|
175
|
+
}
|
|
176
|
+
if !events[3].Minimized || events[3].MinimizedReason != "spam" {
|
|
177
|
+
t.Fatalf("object minimized state was not normalized: %#v", events[3])
|
|
178
|
+
}
|
|
179
|
+
if !events[4].Minimized || events[4].MinimizedReason != "" {
|
|
180
|
+
t.Fatalf("unknown object reason shape should remain minimized without inventing a reason: %#v", events[4])
|
|
181
|
+
}
|
|
182
|
+
if events[5].Minimized {
|
|
183
|
+
t.Fatalf("unknown scalar minimized shape should be tolerated without inventing minimized=true: %#v", events[5])
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func TestIssueBodySelectorUsesOneProviderReadAndVerifiesIdentity(t *testing.T) {
|
|
188
|
+
longBody := "Full exact description\n\n" + strings.Repeat("selected body text stays faithful even when long\n", 220)
|
|
189
|
+
var requests int32
|
|
190
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
191
|
+
atomic.AddInt32(&requests, 1)
|
|
192
|
+
if r.URL.Path != "/repos/o/r/issues/42" {
|
|
193
|
+
t.Fatalf("body selector fetched unrelated Issue data: %s", r.URL.Path)
|
|
194
|
+
}
|
|
195
|
+
w.Header().Set("Content-Type", "application/json")
|
|
196
|
+
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
197
|
+
"id": 987, "number": 42, "state": "open", "title": "Body selector", "body": longBody,
|
|
198
|
+
"html_url": "https://github.com/o/r/issues/42", "user": map[string]any{"login": "alice"},
|
|
199
|
+
})
|
|
200
|
+
}))
|
|
201
|
+
defer server.Close()
|
|
202
|
+
|
|
203
|
+
out, err := readGitHubIssue(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/issues/42#issue-987"))
|
|
204
|
+
if err != nil {
|
|
205
|
+
t.Fatal(err)
|
|
206
|
+
}
|
|
207
|
+
if got := atomic.LoadInt32(&requests); got != 1 {
|
|
208
|
+
t.Fatalf("exact Issue-body selector should use one provider read, got %d", got)
|
|
209
|
+
}
|
|
210
|
+
if !strings.Contains(out, strings.TrimSpace(longBody)) || strings.Contains(out, "Timeline") || strings.Contains(out, "preview truncated") {
|
|
211
|
+
t.Fatalf("exact Issue-body selector was not faithful/narrow:\n%s", out)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
atomic.StoreInt32(&requests, 0)
|
|
215
|
+
_, err = readGitHubIssue(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/issues/42#issue-986"))
|
|
216
|
+
if err == nil || !strings.Contains(err.Error(), "does not belong") || !strings.Contains(err.Error(), "Issue id 987") {
|
|
217
|
+
t.Fatalf("mismatched Issue-body selector was not rejected truthfully: %v", err)
|
|
218
|
+
}
|
|
219
|
+
if got := atomic.LoadInt32(&requests); got != 1 {
|
|
220
|
+
t.Fatalf("mismatched Issue-body selector should stop after identity read, got %d", got)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func TestLargeIssueUsesBoundedOverviewWithSelectorsAndTruthfulOmission(t *testing.T) {
|
|
225
|
+
body := "Opening context that must survive.\n\n" + strings.Repeat("Body paragraph with enough detail to force a bounded preview.\n\n", 35) + "```go\n" + strings.Repeat("fmt.Println(\"inside body fence\")\n", 80) + "```\n\nTAIL BODY MUST NOT APPEAR"
|
|
226
|
+
comments := make([]map[string]any, 0, 30)
|
|
227
|
+
for i := 1; i <= 30; i++ {
|
|
228
|
+
commentBody := fmt.Sprintf("Comment %02d preview marker.\n\n%s\nTAIL COMMENT %02d MUST NOT APPEAR", i, strings.Repeat("long comment paragraph with deterministic context. ", 24), i)
|
|
229
|
+
if i == 2 {
|
|
230
|
+
commentBody = "Pinned answer marker.\n\n" + strings.Repeat("pinned detail. ", 80)
|
|
231
|
+
}
|
|
232
|
+
comments = append(comments, map[string]any{
|
|
233
|
+
"id": i, "event": "commented", "body": commentBody,
|
|
234
|
+
"html_url": fmt.Sprintf("https://github.com/o/r/issues/42#issuecomment-%d", i),
|
|
235
|
+
"user": map[string]any{"login": fmt.Sprintf("user-%02d", i)},
|
|
236
|
+
"created_at": fmt.Sprintf("2026-08-%02dT00:00:00Z", (i%28)+1),
|
|
237
|
+
"is_pinned": i == 2,
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
242
|
+
w.Header().Set("Content-Type", "application/json")
|
|
243
|
+
switch r.URL.Path {
|
|
244
|
+
case "/repos/o/r/issues/42":
|
|
245
|
+
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
246
|
+
"id": 987, "number": 42, "state": "open", "title": "Very large Issue", "body": body,
|
|
247
|
+
"html_url": "https://github.com/o/r/issues/42", "user": map[string]any{"login": "alice"},
|
|
248
|
+
"comments": 35,
|
|
249
|
+
"pinned_comment": map[string]any{"id": 2, "body": "Pinned answer marker.", "html_url": "https://github.com/o/r/issues/42#issuecomment-2", "user": map[string]any{"login": "user-02"}},
|
|
250
|
+
})
|
|
251
|
+
case "/repos/o/r/issues/42/timeline":
|
|
252
|
+
_ = json.NewEncoder(w).Encode(comments)
|
|
253
|
+
case "/repos/o/r/issues/42/parent":
|
|
254
|
+
_ = json.NewEncoder(w).Encode(map[string]any{"number": 10, "title": "Parent", "html_url": "https://github.com/o/r/issues/10"})
|
|
255
|
+
case "/repos/o/r/issues/42/sub_issues":
|
|
256
|
+
_ = json.NewEncoder(w).Encode([]map[string]any{{"number": 43, "title": "Child", "html_url": "https://github.com/o/r/issues/43"}})
|
|
257
|
+
case "/repos/o/r/issues/42/dependencies/blocked_by", "/repos/o/r/issues/42/dependencies/blocking", "/repos/o/r/issues/42/issue-field-values":
|
|
258
|
+
_, _ = io.WriteString(w, `[]`)
|
|
259
|
+
default:
|
|
260
|
+
t.Fatalf("unexpected request: %s", r.URL.Path)
|
|
261
|
+
}
|
|
262
|
+
}))
|
|
263
|
+
defer server.Close()
|
|
264
|
+
|
|
265
|
+
out, err := readGitHubIssue(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/issues/42"))
|
|
266
|
+
if err != nil {
|
|
267
|
+
t.Fatal(err)
|
|
268
|
+
}
|
|
269
|
+
runes := utf8.RuneCountInString(out)
|
|
270
|
+
t.Logf("large Issue overview runes: %d", runes)
|
|
271
|
+
if runes > githubOverviewRunes {
|
|
272
|
+
t.Fatalf("large Issue overview exceeded shared target: %d runes\n%s", runes, out)
|
|
273
|
+
}
|
|
274
|
+
for _, want := range []string{
|
|
275
|
+
"overview: true", "Opening context that must survive.", "Description preview locally truncated",
|
|
276
|
+
"https://github.com/o/r/issues/42#issue-987", "Comment `1` by @user-01",
|
|
277
|
+
"https://github.com/o/r/issues/42#issuecomment-1", "Pinned answer marker", "Parent: [#10 Parent]", "Sub-issue: [#43 Child]",
|
|
278
|
+
"timeline_items_omitted:", "locally omitted from this overview", "comments_provider_complete: false", "Provider-incomplete comment data",
|
|
279
|
+
} {
|
|
280
|
+
if !strings.Contains(out, want) {
|
|
281
|
+
t.Fatalf("bounded overview missing %q:\n%s", want, out)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if strings.Contains(out, "TAIL BODY MUST NOT APPEAR") || strings.Contains(out, "TAIL COMMENT 01 MUST NOT APPEAR") {
|
|
285
|
+
t.Fatalf("overview leaked full subordinate bodies:\n%s", out)
|
|
286
|
+
}
|
|
287
|
+
bodyPreviewStart := strings.Index(out, "## Body preview\n\n")
|
|
288
|
+
if bodyPreviewStart < 0 {
|
|
289
|
+
t.Fatalf("could not locate body preview boundaries:\n%s", out)
|
|
290
|
+
}
|
|
291
|
+
bodyPreviewEnd := strings.Index(out[bodyPreviewStart:], "\n\n> Description preview locally truncated")
|
|
292
|
+
if bodyPreviewEnd < 0 {
|
|
293
|
+
t.Fatalf("could not locate body preview end:\n%s", out)
|
|
294
|
+
}
|
|
295
|
+
preview := out[bodyPreviewStart+len("## Body preview\n\n") : bodyPreviewStart+bodyPreviewEnd]
|
|
296
|
+
if strings.Count(preview, "```")%2 != 0 {
|
|
297
|
+
t.Fatalf("body preview ended inside a Markdown fence:\n%s", preview)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
func TestReadGitHubIssueEmptyLockedAndUnavailableCommentBodies(t *testing.T) {
|
|
302
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
303
|
+
w.Header().Set("Content-Type", "application/json")
|
|
304
|
+
switch r.URL.Path {
|
|
305
|
+
case "/repos/o/r/issues/8":
|
|
306
|
+
_, _ = io.WriteString(w, `{"number":8,"state":"open","title":"Empty","body":null,"locked":true,"comments":1,"user":{"login":"u"}}`)
|
|
307
|
+
case "/repos/o/r/issues/8/timeline":
|
|
308
|
+
_, _ = io.WriteString(w, `[{"id":5,"event":"commented","body":null,"user":{"login":"gone"},"created_at":"2026-08-01T00:00:00Z"}]`)
|
|
309
|
+
case "/repos/o/r/issues/8/parent", "/repos/o/r/issues/8/issue-field-values":
|
|
310
|
+
w.WriteHeader(http.StatusNotFound)
|
|
311
|
+
_, _ = io.WriteString(w, `{"message":"Not Found"}`)
|
|
312
|
+
case "/repos/o/r/issues/8/sub_issues", "/repos/o/r/issues/8/dependencies/blocked_by", "/repos/o/r/issues/8/dependencies/blocking":
|
|
313
|
+
_, _ = io.WriteString(w, `[]`)
|
|
314
|
+
default:
|
|
315
|
+
t.Fatalf("unexpected request %s", r.URL.Path)
|
|
316
|
+
}
|
|
317
|
+
}))
|
|
318
|
+
defer server.Close()
|
|
319
|
+
out, err := readGitHubIssue(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/issues/8"))
|
|
320
|
+
if err != nil {
|
|
321
|
+
t.Fatal(err)
|
|
322
|
+
}
|
|
323
|
+
if !strings.Contains(out, "_No description provided._") || !strings.Contains(out, "_Comment body is unavailable or deleted._") || !strings.Contains(out, "locked: true") {
|
|
324
|
+
t.Fatalf("ordinary empty/locked/deleted state malformed:\n%s", out)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
func TestIssueCommentSelectorUsesOneProviderRead(t *testing.T) {
|
|
329
|
+
var requests int32
|
|
330
|
+
var server *httptest.Server
|
|
331
|
+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
332
|
+
atomic.AddInt32(&requests, 1)
|
|
333
|
+
if r.URL.Path != "/repos/o/r/issues/comments/99" {
|
|
334
|
+
t.Fatalf("selector fetched unrelated Issue data: %s", r.URL.Path)
|
|
335
|
+
}
|
|
336
|
+
w.Header().Set("Content-Type", "application/json")
|
|
337
|
+
_, _ = io.WriteString(w, `{
|
|
338
|
+
"id":99,"issue_url":"`+server.URL+`/repos/o/r/issues/42","html_url":"https://github.com/o/r/issues/42#issuecomment-99",
|
|
339
|
+
"body":"Selected\n<!-- hidden -->\ncomment","user":{"login":"alice"},"author_association":"MEMBER",
|
|
340
|
+
"created_at":"2026-08-01T00:00:00Z","updated_at":"2026-08-01T01:00:00Z","is_pinned":true
|
|
341
|
+
}`)
|
|
342
|
+
}))
|
|
343
|
+
defer server.Close()
|
|
344
|
+
target := parseGitHubTarget("https://github.com/o/r/issues/42#issuecomment-99")
|
|
345
|
+
out, err := readGitHubIssue(context.Background(), testGitHubClient(server.URL, server.URL, ""), target)
|
|
346
|
+
if err != nil {
|
|
347
|
+
t.Fatal(err)
|
|
348
|
+
}
|
|
349
|
+
if got := atomic.LoadInt32(&requests); got != 1 {
|
|
350
|
+
t.Fatalf("exact selector should use one provider read, got %d", got)
|
|
351
|
+
}
|
|
352
|
+
if !strings.Contains(out, "Selected\n\ncomment") || strings.Contains(out, "hidden") || !strings.Contains(out, "pinned: true") {
|
|
353
|
+
t.Fatalf("unexpected targeted comment output:\n%s", out)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
func TestIssueCommentSelectorRejectsCommentFromOtherIssue(t *testing.T) {
|
|
358
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
359
|
+
w.Header().Set("Content-Type", "application/json")
|
|
360
|
+
_, _ = io.WriteString(w, `{"id":99,"issue_url":"https://api.github.com/repos/o/r/issues/41","body":"wrong"}`)
|
|
361
|
+
}))
|
|
362
|
+
defer server.Close()
|
|
363
|
+
_, err := readGitHubIssue(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/issues/42#issuecomment-99"))
|
|
364
|
+
if err == nil || !strings.Contains(err.Error(), "does not belong") {
|
|
365
|
+
t.Fatalf("expected ownership error, got %v", err)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
func TestIssueListFiltersPullRequestsAndPreservesFiltersAndNavigation(t *testing.T) {
|
|
370
|
+
var server *httptest.Server
|
|
371
|
+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
372
|
+
if r.URL.Path != "/repos/o/r/issues" {
|
|
373
|
+
t.Fatalf("unexpected path: %s", r.URL.Path)
|
|
374
|
+
}
|
|
375
|
+
for key, want := range map[string]string{"state": "closed", "labels": "bug,ui", "sort": "updated", "direction": "asc", "page": "2"} {
|
|
376
|
+
if got := r.URL.Query().Get(key); got != want {
|
|
377
|
+
t.Errorf("query %s: got %q want %q", key, got, want)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if r.URL.Query().Get("per_page") != "8" {
|
|
381
|
+
t.Errorf("Issue list provider page size = %q", r.URL.Query().Get("per_page"))
|
|
382
|
+
}
|
|
383
|
+
w.Header().Set("Content-Type", "application/json")
|
|
384
|
+
w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/issues?state=closed&labels=bug%%2Cui&page=1>; rel="prev", <%s/repos/o/r/issues?state=closed&labels=bug%%2Cui&page=3>; rel="next"`, server.URL, server.URL))
|
|
385
|
+
_, _ = io.WriteString(w, `[
|
|
386
|
+
{"number":20,"state":"closed","title":"Actual Issue","html_url":"https://github.com/o/r/issues/20","user":{"login":"alice"},"updated_at":"2026-08-01T00:00:00Z","labels":[{"name":"bug"}]},
|
|
387
|
+
{"number":21,"state":"closed","title":"A PR","html_url":"https://github.com/o/r/pull/21","pull_request":{"html_url":"https://github.com/o/r/pull/21"}}
|
|
388
|
+
]`)
|
|
389
|
+
}))
|
|
390
|
+
defer server.Close()
|
|
391
|
+
target := parseGitHubTarget("https://github.com/o/r/issues?state=closed&labels=bug%2Cui&sort=updated&direction=asc&page=2")
|
|
392
|
+
out, err := readGitHubIssueList(context.Background(), testGitHubClient(server.URL, server.URL, ""), target)
|
|
393
|
+
if err != nil {
|
|
394
|
+
t.Fatal(err)
|
|
395
|
+
}
|
|
396
|
+
if !strings.Contains(out, "#20 Actual Issue") || strings.Contains(out, "A PR") {
|
|
397
|
+
t.Fatalf("Issue list failed PR exclusion:\n%s", out)
|
|
398
|
+
}
|
|
399
|
+
if !strings.Contains(out, "Previous: https://github.com/o/r/issues?") || !strings.Contains(out, "page=1") || !strings.Contains(out, "Next: https://github.com/o/r/issues?") || !strings.Contains(out, "page=3") {
|
|
400
|
+
t.Fatalf("bounded page navigation missing:\n%s", out)
|
|
401
|
+
}
|
|
402
|
+
if !strings.Contains(out, "labels=bug%2Cui") || !strings.Contains(out, "state=closed") {
|
|
403
|
+
t.Fatalf("UI filters were not preserved in navigation:\n%s", out)
|
|
404
|
+
}
|
|
405
|
+
if !strings.Contains(out, "results_indexed: 1") || !strings.Contains(out, "results_local_omitted: 0") {
|
|
406
|
+
t.Fatalf("Issue provider page was not rendered completely:\n%s", out)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
func TestIssueSearchUsesSearchResourceAndSurfacesIncompleteResults(t *testing.T) {
|
|
411
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
412
|
+
if r.URL.Path != "/search/issues" {
|
|
413
|
+
t.Fatalf("unexpected search path: %s", r.URL.Path)
|
|
414
|
+
}
|
|
415
|
+
q := r.URL.Query().Get("q")
|
|
416
|
+
if !strings.Contains(q, "repo:o/r") || !strings.Contains(q, "label:bug") || !strings.Contains(q, "is:issue") {
|
|
417
|
+
t.Errorf("search qualifiers missing from %q", q)
|
|
418
|
+
}
|
|
419
|
+
if r.URL.Query().Get("per_page") != "8" {
|
|
420
|
+
t.Errorf("Issue search provider page size = %q", r.URL.Query().Get("per_page"))
|
|
421
|
+
}
|
|
422
|
+
w.Header().Set("Content-Type", "application/json")
|
|
423
|
+
w.Header().Set("X-RateLimit-Resource", "search")
|
|
424
|
+
_, _ = io.WriteString(w, `{"total_count":90,"incomplete_results":true,"items":[
|
|
425
|
+
{"number":1,"state":"open","title":"Issue","html_url":"https://github.com/o/r/issues/1"},
|
|
426
|
+
{"number":2,"state":"open","title":"PR","pull_request":{"html_url":"https://github.com/o/r/pull/2"}}
|
|
427
|
+
]}`)
|
|
428
|
+
}))
|
|
429
|
+
defer server.Close()
|
|
430
|
+
target := parseGitHubTarget("https://github.com/o/r/issues?q=is%3Aissue+label%3Abug&page=2")
|
|
431
|
+
out, err := readGitHubIssueList(context.Background(), testGitHubClient(server.URL, server.URL, ""), target)
|
|
432
|
+
if err != nil {
|
|
433
|
+
t.Fatal(err)
|
|
434
|
+
}
|
|
435
|
+
if !strings.Contains(out, `query: "is:issue label:bug"`) || !strings.Contains(out, "total_matches: 90") || !strings.Contains(out, "complete: false") || !strings.Contains(out, "marked this search result set as incomplete") {
|
|
436
|
+
t.Fatalf("search truth missing:\n%s", out)
|
|
437
|
+
}
|
|
438
|
+
if strings.Contains(out, "PR") {
|
|
439
|
+
t.Fatalf("search result leaked PR into Issues view:\n%s", out)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
func TestGitHubSearchResourceQualifierIsTokenAwareAndRejectsConflict(t *testing.T) {
|
|
444
|
+
for _, tt := range []struct {
|
|
445
|
+
query string
|
|
446
|
+
want string
|
|
447
|
+
}{
|
|
448
|
+
{query: "bug is:pr is:open", want: "pull_request"},
|
|
449
|
+
{query: "bug IS:ISSUE", want: "issue"},
|
|
450
|
+
{query: `"is:pr" bug`, want: ""},
|
|
451
|
+
{query: `label:"is:pr" bug`, want: ""},
|
|
452
|
+
{query: "this-is:pr bug", want: ""},
|
|
453
|
+
} {
|
|
454
|
+
got, err := githubSearchResourceQualifier(tt.query)
|
|
455
|
+
if err != nil || got != tt.want {
|
|
456
|
+
t.Fatalf("githubSearchResourceQualifier(%q)=%q,%v want %q,nil", tt.query, got, err, tt.want)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if _, err := githubSearchResourceQualifier("is:issue bug is:pr"); err == nil || !strings.Contains(err.Error(), "conflicting") {
|
|
460
|
+
t.Fatalf("conflicting explicit resource qualifiers were not rejected: %v", err)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
func TestIssueSearchConflictingResourceQualifiersFailBeforeProvider(t *testing.T) {
|
|
465
|
+
var calls int32
|
|
466
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
467
|
+
atomic.AddInt32(&calls, 1)
|
|
468
|
+
w.WriteHeader(http.StatusInternalServerError)
|
|
469
|
+
}))
|
|
470
|
+
defer server.Close()
|
|
471
|
+
_, err := readGitHubIssueList(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/issues?q=is%3Aissue+is%3Apr"))
|
|
472
|
+
if err == nil || !strings.Contains(err.Error(), "conflicting") {
|
|
473
|
+
t.Fatalf("conflicting Issue/PR search was not rejected truthfully: %v", err)
|
|
474
|
+
}
|
|
475
|
+
if atomic.LoadInt32(&calls) != 0 {
|
|
476
|
+
t.Fatalf("conflicting search made %d provider calls", calls)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
func TestIssueRecognizedProviderFailuresStayNative(t *testing.T) {
|
|
481
|
+
for _, tt := range []struct {
|
|
482
|
+
name string
|
|
483
|
+
status int
|
|
484
|
+
head map[string]string
|
|
485
|
+
want string
|
|
486
|
+
}{
|
|
487
|
+
{name: "not found", status: http.StatusNotFound, want: "may be private"},
|
|
488
|
+
{name: "forbidden", status: http.StatusForbidden, want: "denied access"},
|
|
489
|
+
{name: "rate", status: http.StatusTooManyRequests, head: map[string]string{"Retry-After": "60", "X-RateLimit-Resource": "core"}, want: "rate limit exceeded"},
|
|
490
|
+
} {
|
|
491
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
492
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
493
|
+
for key, value := range tt.head {
|
|
494
|
+
w.Header().Set(key, value)
|
|
495
|
+
}
|
|
496
|
+
w.WriteHeader(tt.status)
|
|
497
|
+
_, _ = io.WriteString(w, `{"message":"provider response"}`)
|
|
498
|
+
}))
|
|
499
|
+
defer server.Close()
|
|
500
|
+
result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/issues/1"), testGitHubClient(server.URL, server.URL, ""))
|
|
501
|
+
if result.Outcome != GitHubNativeFailure || result.Err == nil || !strings.Contains(strings.ToLower(result.Err.Error()), tt.want) {
|
|
502
|
+
t.Fatalf("unexpected native failure: %#v", result)
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
func TestIssueURLPointingToPullRequestDoesNotConsumePRConversation(t *testing.T) {
|
|
509
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
510
|
+
if r.URL.Path != "/repos/o/r/issues/5" {
|
|
511
|
+
t.Fatalf("reader should stop after Issue identity reveals PR, got %s", r.URL.Path)
|
|
512
|
+
}
|
|
513
|
+
w.Header().Set("Content-Type", "application/json")
|
|
514
|
+
_, _ = io.WriteString(w, `{"number":5,"title":"PR identity","pull_request":{"html_url":"https://github.com/o/r/pull/5"}}`)
|
|
515
|
+
}))
|
|
516
|
+
defer server.Close()
|
|
517
|
+
_, err := readGitHubIssue(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/issues/5"))
|
|
518
|
+
if err == nil || !strings.Contains(err.Error(), "pull request") || !strings.Contains(err.Error(), "/pull/5") {
|
|
519
|
+
t.Fatalf("expected PR ownership boundary, got %v", err)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
func TestLabelAndMilestoneReadersStayBoundedAndFilterPRs(t *testing.T) {
|
|
524
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
525
|
+
w.Header().Set("Content-Type", "application/json")
|
|
526
|
+
switch r.URL.Path {
|
|
527
|
+
case "/repos/o/r/labels/bug":
|
|
528
|
+
_, _ = io.WriteString(w, `{"name":"bug","color":"ff0000","description":"Something broke"}`)
|
|
529
|
+
case "/repos/o/r/issues":
|
|
530
|
+
if label := r.URL.Query().Get("labels"); label != "" && label != "bug" {
|
|
531
|
+
t.Errorf("unexpected label filter: %q", label)
|
|
532
|
+
}
|
|
533
|
+
_, _ = io.WriteString(w, `[
|
|
534
|
+
{"number":1,"state":"open","title":"Issue","html_url":"https://github.com/o/r/issues/1"},
|
|
535
|
+
{"number":2,"state":"open","title":"PR","pull_request":{"html_url":"https://github.com/o/r/pull/2"}}
|
|
536
|
+
]`)
|
|
537
|
+
case "/repos/o/r/milestones/7":
|
|
538
|
+
_, _ = io.WriteString(w, `{"number":7,"state":"open","title":"v2","description":"Ship it","open_issues":1,"closed_issues":2,"due_on":"2026-09-01T00:00:00Z"}`)
|
|
539
|
+
default:
|
|
540
|
+
t.Fatalf("unexpected path: %s", r.URL.Path)
|
|
541
|
+
}
|
|
542
|
+
}))
|
|
543
|
+
defer server.Close()
|
|
544
|
+
client := testGitHubClient(server.URL, server.URL, "")
|
|
545
|
+
labelOut, err := readGitHubLabel(context.Background(), client, parseGitHubTarget("https://github.com/o/r/labels/bug"))
|
|
546
|
+
if err != nil {
|
|
547
|
+
t.Fatal(err)
|
|
548
|
+
}
|
|
549
|
+
if !strings.Contains(labelOut, "Something broke") || !strings.Contains(labelOut, "#1 Issue") || strings.Contains(labelOut, "PR") || !strings.Contains(labelOut, "Filtered Issues") {
|
|
550
|
+
t.Fatalf("label output incorrect:\n%s", labelOut)
|
|
551
|
+
}
|
|
552
|
+
milestoneOut, err := readGitHubMilestone(context.Background(), client, parseGitHubTarget("https://github.com/o/r/milestone/7"))
|
|
553
|
+
if err != nil {
|
|
554
|
+
t.Fatal(err)
|
|
555
|
+
}
|
|
556
|
+
if !strings.Contains(milestoneOut, "Milestone: v2") || !strings.Contains(milestoneOut, "Ship it") || !strings.Contains(milestoneOut, "#1 Issue") || strings.Contains(milestoneOut, "PR") {
|
|
557
|
+
t.Fatalf("milestone output incorrect:\n%s", milestoneOut)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
func TestLabelAndMilestoneListPaginationUsesUIURLs(t *testing.T) {
|
|
562
|
+
var server *httptest.Server
|
|
563
|
+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
564
|
+
w.Header().Set("Content-Type", "application/json")
|
|
565
|
+
switch r.URL.Path {
|
|
566
|
+
case "/repos/o/r/labels":
|
|
567
|
+
w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/labels?page=2>; rel="next"`, server.URL))
|
|
568
|
+
_, _ = io.WriteString(w, `[{"name":"bug","description":"Bug label"}]`)
|
|
569
|
+
case "/repos/o/r/milestones":
|
|
570
|
+
w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/milestones?page=2>; rel="next"`, server.URL))
|
|
571
|
+
_, _ = io.WriteString(w, `[{"number":7,"state":"open","title":"v2","open_issues":1,"closed_issues":2}]`)
|
|
572
|
+
default:
|
|
573
|
+
t.Fatalf("unexpected path: %s", r.URL.Path)
|
|
574
|
+
}
|
|
575
|
+
}))
|
|
576
|
+
defer server.Close()
|
|
577
|
+
client := testGitHubClient(server.URL, server.URL, "")
|
|
578
|
+
labels, err := readGitHubLabelList(context.Background(), client, parseGitHubTarget("https://github.com/o/r/labels"))
|
|
579
|
+
if err != nil {
|
|
580
|
+
t.Fatal(err)
|
|
581
|
+
}
|
|
582
|
+
if !strings.Contains(labels, "Bug label") || !strings.Contains(labels, "Next: https://github.com/o/r/labels?page=2") {
|
|
583
|
+
t.Fatalf("label list navigation incorrect:\n%s", labels)
|
|
584
|
+
}
|
|
585
|
+
milestones, err := readGitHubMilestones(context.Background(), client, parseGitHubTarget("https://github.com/o/r/milestones?state=open"))
|
|
586
|
+
if err != nil {
|
|
587
|
+
t.Fatal(err)
|
|
588
|
+
}
|
|
589
|
+
if !strings.Contains(milestones, "v2") || !strings.Contains(milestones, "Next: https://github.com/o/r/milestones?page=2&state=open") {
|
|
590
|
+
t.Fatalf("milestone list navigation incorrect:\n%s", milestones)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
func TestGitHubRESTPagesRejectsPaginationCycle(t *testing.T) {
|
|
595
|
+
var server *httptest.Server
|
|
596
|
+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
597
|
+
w.Header().Set("Content-Type", "application/json")
|
|
598
|
+
w.Header().Set("Link", fmt.Sprintf(`<%s/items>; rel="next"`, server.URL))
|
|
599
|
+
_, _ = io.WriteString(w, `[]`)
|
|
600
|
+
}))
|
|
601
|
+
defer server.Close()
|
|
602
|
+
_, err := testGitHubClient(server.URL, server.URL, "").RESTPages(context.Background(), "/items", "")
|
|
603
|
+
if err == nil || !strings.Contains(err.Error(), "pagination returned a cycle") {
|
|
604
|
+
t.Fatalf("expected pagination cycle error, got %v", err)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
func TestPageFromGitHubLink(t *testing.T) {
|
|
609
|
+
for _, tt := range []struct {
|
|
610
|
+
raw string
|
|
611
|
+
page int
|
|
612
|
+
ok bool
|
|
613
|
+
}{
|
|
614
|
+
{raw: "https://api.github.com/x?page=3", page: 3, ok: true},
|
|
615
|
+
{raw: "https://api.github.com/x", ok: false},
|
|
616
|
+
{raw: "not a url%", ok: false},
|
|
617
|
+
} {
|
|
618
|
+
page, ok := pageFromGitHubLink(tt.raw)
|
|
619
|
+
if page != tt.page || ok != tt.ok {
|
|
620
|
+
t.Fatalf("pageFromGitHubLink(%q) = %d,%v want %d,%v", tt.raw, page, ok, tt.page, tt.ok)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
func TestIssueSearchQueryRetainsUserText(t *testing.T) {
|
|
626
|
+
target := parseGitHubTarget("https://github.com/o/r/issues?q=is%3Aissue+label%3A%22good+first+issue%22&page=2")
|
|
627
|
+
if target == nil || target.Query.Get("q") != `is:issue label:"good first issue"` {
|
|
628
|
+
t.Fatalf("query parse changed: %#v", target)
|
|
629
|
+
}
|
|
630
|
+
parsed, _ := url.Parse(githubTargetPageURL(target, 3))
|
|
631
|
+
if parsed.Query().Get("q") != target.Query.Get("q") || parsed.Query().Get("page") != "3" {
|
|
632
|
+
t.Fatalf("UI navigation lost query: %s", parsed.String())
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
func TestRenderIssueTimelineMinimizedComment(t *testing.T) {
|
|
637
|
+
body := "secret body"
|
|
638
|
+
lines := renderTimelineComment(githubIssueComment{Body: &body, User: githubUser{Login: "u"}, Minimized: true, MinimizedReason: "outdated"})
|
|
639
|
+
out := strings.Join(lines, "\n")
|
|
640
|
+
if strings.Contains(out, "secret body") || !strings.Contains(out, "minimized by GitHub (outdated)") {
|
|
641
|
+
t.Fatalf("minimized state not truthful: %s", out)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
func TestRenderIssueFieldValues(t *testing.T) {
|
|
646
|
+
value := githubIssueFieldValue{IssueFieldName: "Tags", Value: []any{"a", "b"}}
|
|
647
|
+
if got := renderIssueFieldValue(value); got != `["a","b"]` {
|
|
648
|
+
t.Fatalf("unexpected field value: %q", got)
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
func TestCopySelectedQueryDoesNotForwardUnknownUIParams(t *testing.T) {
|
|
653
|
+
source := url.Values{"state": []string{"open"}, "evil": []string{"x"}, "labels": []string{"bug"}}
|
|
654
|
+
got := copySelectedQuery(source, []string{"state", "labels"})
|
|
655
|
+
if got.Get("state") != "open" || got.Get("labels") != "bug" || got.Get("evil") != "" {
|
|
656
|
+
t.Fatalf("unexpected selected query: %#v", got)
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
func TestIssueErrorDoesNotExposePrivateBody(t *testing.T) {
|
|
661
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
662
|
+
w.WriteHeader(http.StatusForbidden)
|
|
663
|
+
_, _ = io.WriteString(w, `{"message":"private body: do-not-print"}`)
|
|
664
|
+
}))
|
|
665
|
+
defer server.Close()
|
|
666
|
+
client := testGitHubClient(server.URL, server.URL, "fake-private-token")
|
|
667
|
+
_, err := readGitHubIssue(context.Background(), client, parseGitHubTarget("https://github.com/o/r/issues/1"))
|
|
668
|
+
if err == nil {
|
|
669
|
+
t.Fatal("expected error")
|
|
670
|
+
}
|
|
671
|
+
if strings.Contains(err.Error(), "do-not-print") || strings.Contains(err.Error(), "fake-private-token") {
|
|
672
|
+
t.Fatalf("private provider content leaked: %q", err)
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
func TestIssueListSearchRateErrorReportsSearchResource(t *testing.T) {
|
|
677
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
678
|
+
w.Header().Set("X-RateLimit-Remaining", "0")
|
|
679
|
+
w.Header().Set("X-RateLimit-Resource", "search")
|
|
680
|
+
w.WriteHeader(http.StatusForbidden)
|
|
681
|
+
_, _ = io.WriteString(w, `{"message":"API rate limit exceeded"}`)
|
|
682
|
+
}))
|
|
683
|
+
defer server.Close()
|
|
684
|
+
_, err := readGitHubIssueList(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/issues?q=is%3Aissue"))
|
|
685
|
+
if err == nil || !strings.Contains(err.Error(), "Resource: search") {
|
|
686
|
+
t.Fatalf("search quota truth missing: %v", err)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
func TestIssueCommentSelectorParsing(t *testing.T) {
|
|
691
|
+
if id, ok, err := parseIssueCommentSelector("issuecomment-123"); !ok || err != nil || id != 123 {
|
|
692
|
+
t.Fatalf("valid selector failed: %d %v %v", id, ok, err)
|
|
693
|
+
}
|
|
694
|
+
if _, ok, err := parseIssueCommentSelector("issuecomment-nope"); !ok || err == nil {
|
|
695
|
+
t.Fatalf("invalid selector not rejected: %v %v", ok, err)
|
|
696
|
+
}
|
|
697
|
+
if _, ok, err := parseIssueCommentSelector("discussion_r123"); ok || err != nil {
|
|
698
|
+
t.Fatalf("non-Issue selector should be unclaimed: %v %v", ok, err)
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
func TestIssueTimelineKeepsUnknownPotentiallySubstantiveEvents(t *testing.T) {
|
|
703
|
+
line, ok := renderIssueTimelineState(githubTimelineEvent{Event: "future_meaningful_event", Actor: githubUser{Login: "alice"}, CreatedAt: "2026-08-01T00:00:00Z"})
|
|
704
|
+
if !ok || !strings.Contains(line, "future meaningful event") || !strings.Contains(line, "@alice") {
|
|
705
|
+
t.Fatalf("unknown substantive event was lost: %q %v", line, ok)
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
func TestIssueTimelineOmitsKnownProviderBookkeeping(t *testing.T) {
|
|
710
|
+
for _, event := range []string{"mentioned", "subscribed", "unsubscribed"} {
|
|
711
|
+
if line, ok := renderIssueTimelineState(githubTimelineEvent{Event: event}); ok || line != "" {
|
|
712
|
+
t.Fatalf("bookkeeping %q should be omitted, got %q", event, line)
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
func TestGitHubIssueTimeHelper(t *testing.T) {
|
|
718
|
+
if got := parseGitHubTime("2026-08-01T00:00:00Z"); got.IsZero() {
|
|
719
|
+
t.Fatal("expected RFC3339 time to parse")
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
func TestIssueSearchResponseJSONShape(t *testing.T) {
|
|
724
|
+
var result githubIssueSearchResponse
|
|
725
|
+
if err := json.Unmarshal([]byte(`{"total_count":1,"incomplete_results":false,"items":[{"number":1,"title":"x"}]}`), &result); err != nil {
|
|
726
|
+
t.Fatal(err)
|
|
727
|
+
}
|
|
728
|
+
if result.TotalCount != 1 || len(result.Items) != 1 || result.Items[0].Number != 1 {
|
|
729
|
+
t.Fatalf("unexpected decode: %#v", result)
|
|
730
|
+
}
|
|
731
|
+
}
|