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,715 @@
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 TestParseGitHubPullTargetsAndSelectors(t *testing.T) {
17
+ for _, raw := range []string{
18
+ "https://github.com/o/r/pulls",
19
+ "https://github.com/o/r/pulls?page=2",
20
+ "https://github.com/o/r/pulls?state=closed&base=main&sort=updated&direction=asc",
21
+ "https://github.com/o/r/pulls?q=is%3Apr+is%3Aopen",
22
+ } {
23
+ target := parseGitHubTarget(raw)
24
+ if target == nil || target.Kind != GitHubTargetPullList || target.Owner != "o" || target.Repo != "r" {
25
+ t.Fatalf("unexpected pull-list target for %s: %#v", raw, target)
26
+ }
27
+ }
28
+ for _, tt := range []struct {
29
+ raw string
30
+ fragment string
31
+ }{
32
+ {raw: "https://github.com/o/r/pull/42"},
33
+ {raw: "https://github.com/o/r/pull/42#issue-987", fragment: "issue-987"},
34
+ {raw: "https://github.com/o/r/pull/42#issuecomment-99", fragment: "issuecomment-99"},
35
+ {raw: "https://github.com/o/r/pull/42#discussion_r123", fragment: "discussion_r123"},
36
+ {raw: "https://github.com/o/r/pull/42#pullrequestreview-456", fragment: "pullrequestreview-456"},
37
+ } {
38
+ target := parseGitHubTarget(tt.raw)
39
+ if target == nil || target.Kind != GitHubTargetPull || target.Number != 42 || target.Fragment != tt.fragment {
40
+ t.Fatalf("unexpected pull target for %s: %#v", tt.raw, target)
41
+ }
42
+ }
43
+ for _, raw := range []string{
44
+ "https://github.com/o/r/pull/nope",
45
+ "https://github.com/o/r/pull/42/unknown",
46
+ "https://github.com/o/r/pulls/42",
47
+ } {
48
+ if target := parseGitHubTarget(raw); target != nil {
49
+ t.Fatalf("unsupported PR route %s should remain unsupported, got %#v", raw, target)
50
+ }
51
+ }
52
+ }
53
+
54
+ func TestGitHubPullListUsesRESTFiltersDraftStateAndNavigation(t *testing.T) {
55
+ var calls int32
56
+ var server *httptest.Server
57
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58
+ atomic.AddInt32(&calls, 1)
59
+ if r.URL.Path != "/repos/o/r/pulls" {
60
+ t.Fatalf("unexpected PR-list path: %s", r.URL.Path)
61
+ }
62
+ for key, want := range map[string]string{
63
+ "state": "all", "head": "alice:feature", "base": "main", "sort": "updated", "direction": "asc", "per_page": "8", "page": "2",
64
+ } {
65
+ if got := r.URL.Query().Get(key); got != want {
66
+ t.Errorf("PR-list query %s=%q want %q (%s)", key, got, want, r.URL.RawQuery)
67
+ }
68
+ }
69
+ w.Header().Set("Content-Type", "application/json")
70
+ w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/pulls?state=all&page=1>; rel="prev", <%s/repos/o/r/pulls?state=all&page=3>; rel="next"`, server.URL, server.URL))
71
+ _, _ = io.WriteString(w, `[
72
+ {"number":10,"state":"open","draft":false,"title":"Open PR","html_url":"https://github.com/o/r/pull/10","user":{"login":"alice"},"updated_at":"2026-08-10T00:00:00Z","head":{"label":"alice:feature"},"base":{"ref":"main"}},
73
+ {"number":11,"state":"open","draft":true,"title":"Draft PR","html_url":"https://github.com/o/r/pull/11","user":{"login":"bob"},"created_at":"2026-08-09T00:00:00Z","head":{"label":"bob:wip"},"base":{"ref":"main"}},
74
+ {"number":12,"state":"closed","draft":false,"title":"Closed PR","html_url":"https://github.com/o/r/pull/12","user":{"login":"carol"},"updated_at":"2026-08-08T00:00:00Z"}
75
+ ]`)
76
+ }))
77
+ defer server.Close()
78
+
79
+ target := parseGitHubTarget("https://github.com/o/r/pulls?state=all&head=alice%3Afeature&base=main&sort=updated&direction=asc&page=2")
80
+ out, err := readGitHubPullList(context.Background(), testGitHubClient(server.URL, server.URL, ""), target)
81
+ if err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ for _, want := range []string{
85
+ `repository: "o/r"`, "view: pull_requests", `page: "2"`, "results_indexed: 3", "results_local_omitted: 0", "# Pull Requests",
86
+ "[#10 Open PR](https://github.com/o/r/pull/10) — open · @alice · updated 2026-08-10T00:00:00Z",
87
+ "[#11 Draft PR](https://github.com/o/r/pull/11) — draft · @bob · created 2026-08-09T00:00:00Z",
88
+ "[#12 Closed PR](https://github.com/o/r/pull/12) — closed · @carol",
89
+ "Previous: https://github.com/o/r/pulls?", "page=1", "Next: https://github.com/o/r/pulls?", "page=3",
90
+ "base=main", "head=alice%3Afeature", "sort=updated", "direction=asc",
91
+ } {
92
+ if !strings.Contains(out, want) {
93
+ t.Fatalf("native PR list missing %q:\n%s", want, out)
94
+ }
95
+ }
96
+ if atomic.LoadInt32(&calls) != 1 {
97
+ t.Fatalf("PR list should use exactly one provider request, got %d", calls)
98
+ }
99
+ }
100
+
101
+ func TestGitHubPullSearchAndIssuesPRSearchShareTruthfulSemantics(t *testing.T) {
102
+ for _, raw := range []string{
103
+ "https://github.com/o/r/pulls?q=label%3Abug&page=2",
104
+ "https://github.com/o/r/issues?q=is%3Apr+label%3Abug&page=2",
105
+ } {
106
+ t.Run(raw, func(t *testing.T) {
107
+ var calls int32
108
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
109
+ atomic.AddInt32(&calls, 1)
110
+ if r.URL.Path != "/search/issues" {
111
+ t.Fatalf("PR search used wrong endpoint: %s", r.URL.Path)
112
+ }
113
+ q := r.URL.Query().Get("q")
114
+ if !strings.Contains(q, "repo:o/r") || !strings.Contains(q, "is:pr") || strings.Contains(q, "is:issue") || !strings.Contains(q, "label:bug") {
115
+ t.Fatalf("PR search provider query lost semantics: %q", q)
116
+ }
117
+ if r.URL.Query().Get("page") != "2" || r.URL.Query().Get("per_page") != "8" {
118
+ t.Fatalf("PR search pagination wrong: %s", r.URL.RawQuery)
119
+ }
120
+ w.Header().Set("Content-Type", "application/json")
121
+ _, _ = io.WriteString(w, `{"total_count":1201,"incomplete_results":false,"items":[
122
+ {"number":20,"state":"open","draft":true,"title":"PR result","html_url":"https://github.com/o/r/issues/20","pull_request":{"html_url":"https://github.com/o/r/pull/20"},"user":{"login":"alice"},"updated_at":"2026-08-12T00:00:00Z","labels":[{"name":"bug"}]}
123
+ ]}`)
124
+ }))
125
+ defer server.Close()
126
+
127
+ target := parseGitHubTarget(raw)
128
+ var (
129
+ out string
130
+ err error
131
+ )
132
+ if target.Kind == GitHubTargetPullList {
133
+ out, err = readGitHubPullList(context.Background(), testGitHubClient(server.URL, server.URL, ""), target)
134
+ } else {
135
+ out, err = readGitHubIssueList(context.Background(), testGitHubClient(server.URL, server.URL, ""), target)
136
+ }
137
+ if err != nil {
138
+ t.Fatal(err)
139
+ }
140
+ for _, want := range []string{
141
+ "view: pull_requests", "total_matches: 1201", "provider_result_ceiling: 1000", "[#20 PR result](https://github.com/o/r/pull/20)", "draft", "@alice", "labels: bug",
142
+ } {
143
+ if !strings.Contains(out, want) {
144
+ t.Fatalf("PR search output missing %q:\n%s", want, out)
145
+ }
146
+ }
147
+ if atomic.LoadInt32(&calls) != 1 {
148
+ t.Fatalf("PR search should use one provider request, got %d", calls)
149
+ }
150
+ })
151
+ }
152
+ }
153
+
154
+ func TestGitHubPullListRejectsUnsupportedOrConflictingQueryBeforeProvider(t *testing.T) {
155
+ for _, raw := range []string{
156
+ "https://github.com/o/r/pulls?unknown=x",
157
+ "https://github.com/o/r/pulls?state=merged",
158
+ "https://github.com/o/r/pulls?page=zero",
159
+ "https://github.com/o/r/pulls?per_page=10",
160
+ "https://github.com/o/r/pulls?per_page=101",
161
+ "https://github.com/o/r/pulls?q=is%3Aissue",
162
+ "https://github.com/o/r/pulls?q=is%3Apr+is%3Aissue",
163
+ "https://github.com/o/r/pulls?q=is%3Apr&state=open",
164
+ } {
165
+ t.Run(raw, func(t *testing.T) {
166
+ var calls int32
167
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
168
+ atomic.AddInt32(&calls, 1)
169
+ w.WriteHeader(http.StatusInternalServerError)
170
+ }))
171
+ defer server.Close()
172
+ _, err := readGitHubPullList(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget(raw))
173
+ if err == nil {
174
+ t.Fatalf("unsupported/conflicting PR query was accepted: %s", raw)
175
+ }
176
+ if atomic.LoadInt32(&calls) != 0 {
177
+ t.Fatalf("unsupported/conflicting PR query made %d provider calls: %s", calls, raw)
178
+ }
179
+ })
180
+ }
181
+ }
182
+
183
+ func TestPullSelectorParsing(t *testing.T) {
184
+ if id, ok, err := parsePullDiscussionSelector("discussion_r123"); !ok || err != nil || id != 123 {
185
+ t.Fatalf("discussion selector failed: %d %v %v", id, ok, err)
186
+ }
187
+ if _, ok, err := parsePullDiscussionSelector("discussion_rx"); !ok || err == nil {
188
+ t.Fatalf("invalid discussion selector not rejected: %v %v", ok, err)
189
+ }
190
+ if id, ok, err := parsePullReviewSelector("pullrequestreview-456"); !ok || err != nil || id != 456 {
191
+ t.Fatalf("review selector failed: %d %v %v", id, ok, err)
192
+ }
193
+ if _, ok, err := parsePullReviewSelector("pullrequestreview-nope"); !ok || err == nil {
194
+ t.Fatalf("invalid review selector not rejected: %v %v", ok, err)
195
+ }
196
+ }
197
+
198
+ func TestReadGitHubPullRequestAnonymousBoundedOverview(t *testing.T) {
199
+ var timelineCalls, reviewCalls, reviewCommentCalls, graphqlCalls int32
200
+ var server *httptest.Server
201
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
202
+ w.Header().Set("Content-Type", "application/json")
203
+ switch r.URL.Path {
204
+ case "/repos/o/r/pulls/42":
205
+ _, _ = io.WriteString(w, `{
206
+ "id":123456789,"number":42,"state":"open","draft":false,"merged":false,"title":"Improve things",
207
+ "body":"Visible PR body\n<!-- hidden automation -->\nStill visible","html_url":"https://github.com/o/r/pull/42",
208
+ "user":{"login":"author"},"created_at":"2026-08-01T00:00:00Z","updated_at":"2026-08-05T00:00:00Z",
209
+ "comments":2,"review_comments":3,"commits":2,"additions":50,"deletions":10,"changed_files":4,
210
+ "head":{"label":"fork:feature","ref":"feature","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
211
+ "base":{"label":"o:main","ref":"main","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
212
+ }`)
213
+ case "/repos/o/r/issues/42":
214
+ _, _ = io.WriteString(w, `{"id":987654321,"number":42,"title":"Improve things","body":"Visible PR body","html_url":"https://github.com/o/r/pull/42","pull_request":{"html_url":"https://github.com/o/r/pull/42"}}`)
215
+ case "/repos/o/r/issues/42/timeline":
216
+ atomic.AddInt32(&timelineCalls, 1)
217
+ if r.URL.Query().Get("page") != "" {
218
+ t.Fatal("PR overview followed timeline pagination")
219
+ }
220
+ w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/issues/42/timeline?per_page=100&page=2>; rel="next"`, server.URL))
221
+ _, _ = io.WriteString(w, `[
222
+ {"id":900,"event":"commented","body":"Normal bot comment\n<!-- hidden -->\nkept","user":{"login":"ci-bot","type":"Bot"},"author_association":"NONE","created_at":"2026-08-02T00:00:00Z","html_url":"https://github.com/o/r/pull/42#issuecomment-900"},
223
+ {"id":500,"event":"reviewed","body":"Review duplicate body","state":"approved","user":{"login":"reviewer"},"created_at":"2026-08-02T01:00:00Z"},
224
+ {"event":"head_ref_force_pushed","actor":{"login":"author"},"commit_id":"dddddddddddddddddddddddddddddddddddddddd","created_at":"2026-08-03T00:00:00Z"},
225
+ {"event":"base_ref_changed","actor":{"login":"author"},"created_at":"2026-08-03T01:00:00Z"},
226
+ {"event":"review_requested","actor":{"login":"author"},"requested_reviewer":{"login":"reviewer"},"created_at":"2026-08-03T02:00:00Z"},
227
+ {"event":"committed","sha":"cccccccccccccccccccccccccccccccccccccccc","message":"Second commit\nmore","created_at":"2026-08-03T03:00:00Z"},
228
+ {"event":"ready_for_review","actor":{"login":"author"},"created_at":"2026-08-04T00:00:00Z"},
229
+ {"event":"review_request_removed","actor":{"login":"author"},"requested_reviewer":{"login":"reviewer"},"created_at":"2026-08-04T01:00:00Z"},
230
+ {"event":"mentioned","actor":{"login":"noise"},"created_at":"2026-08-04T02:00:00Z"}
231
+ ]`)
232
+ case "/repos/o/r/pulls/42/reviews":
233
+ atomic.AddInt32(&reviewCalls, 1)
234
+ w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/pulls/42/reviews?per_page=100&page=2>; rel="next"`, server.URL))
235
+ _, _ = io.WriteString(w, `[
236
+ {"id":500,"state":"APPROVED","body":"Review duplicate body\n<!-- review hidden -->","commit_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","submitted_at":"2026-08-02T01:00:00Z","html_url":"https://github.com/o/r/pull/42#pullrequestreview-500","user":{"login":"reviewer"},"author_association":"MEMBER"},
237
+ {"id":501,"state":"COMMENTED","body":"","commit_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","submitted_at":"2026-08-02T03:00:00Z","html_url":"https://github.com/o/r/pull/42#pullrequestreview-501","user":{"login":"outsider"},"author_association":"NONE"}
238
+ ]`)
239
+ case "/repos/o/r/pulls/42/comments":
240
+ atomic.AddInt32(&reviewCommentCalls, 1)
241
+ w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/pulls/42/comments?per_page=100&page=2>; rel="next"`, server.URL))
242
+ _, _ = io.WriteString(w, `[
243
+ {"id":100,"pull_request_review_id":500,"path":"a.go","original_line":10,"side":"RIGHT","body":"Root comment","user":{"login":"reviewer"},"author_association":"MEMBER","created_at":"2026-08-02T01:10:00Z","html_url":"https://github.com/o/r/pull/42#discussion_r100"},
244
+ {"id":101,"pull_request_review_id":501,"in_reply_to_id":100,"path":"a.go","original_line":10,"side":"RIGHT","body":"Reply from outsider","user":{"login":"outsider"},"author_association":"NONE","created_at":"2026-08-02T03:10:00Z","html_url":"https://github.com/o/r/pull/42#discussion_r101"},
245
+ {"id":200,"pull_request_review_id":501,"path":"b.go","line":22,"side":"LEFT","body":"Second thread","user":{"login":"outsider"},"author_association":"NONE","created_at":"2026-08-02T04:00:00Z","html_url":"https://github.com/o/r/pull/42#discussion_r200"}
246
+ ]`)
247
+ case "/graphql":
248
+ atomic.AddInt32(&graphqlCalls, 1)
249
+ t.Fatal("anonymous PR read must not call GraphQL")
250
+ default:
251
+ t.Fatalf("unexpected request %s?%s", r.URL.Path, r.URL.RawQuery)
252
+ }
253
+ }))
254
+ defer server.Close()
255
+
256
+ target := parseGitHubTarget("https://github.com/o/r/pull/42")
257
+ out, err := readGitHubPullRequest(context.Background(), testGitHubClient(server.URL, server.URL, ""), target)
258
+ if err != nil {
259
+ t.Fatal(err)
260
+ }
261
+ for _, want := range []string{
262
+ `state: "open"`, `overview: true`, `pull_request_id: 123456789`, `issue_id: 987654321`, `base: "o:main"`, `head: "fork:feature"`, `changed_files: 4`, `additions: 50`, `deletions: 10`,
263
+ "Visible PR body", "Still visible", "Full description: https://github.com/o/r/pull/42#issue-987654321", "Comment `900` by @ci-bot", "Normal bot comment", "kept", "Selector: https://github.com/o/r/pull/42#issuecomment-900",
264
+ "force-pushed the head ref to `dddddddddddd`", "changed the base ref", "requested review from @reviewer", "added commit `cccccccccccc` — Second commit", "marked the Pull Request ready for review", "removed review request from @reviewer",
265
+ "Review `500` — APPROVED by @reviewer", "Review duplicate body", "Selector: https://github.com/o/r/pull/42#pullrequestreview-500", "Review `501` — COMMENTED by @outsider",
266
+ "Thread `100` by @reviewer", "Coordinate: `a.go` line 10 right", "Root comment", "Replies: 1", "Selector: https://github.com/o/r/pull/42#discussion_r100", "Thread `200` by @outsider", "Second thread",
267
+ "conversation_provider_more_available: true", "reviews_provider_more_available: true", "review_comments_provider_more_available: true", "thread_state_enrichment: unavailable_without_auth",
268
+ "/pull/42/files", "/pull/42/commits", "/pull/42/checks",
269
+ } {
270
+ if !strings.Contains(out, want) {
271
+ t.Fatalf("PR output missing %q:\n%s", want, out)
272
+ }
273
+ }
274
+ if strings.Contains(out, "hidden automation") || strings.Contains(out, "review hidden") || strings.Contains(out, "@noise") || strings.Contains(out, "Reply from outsider") || strings.Contains(out, "#issue-123456789") {
275
+ t.Fatalf("PR human-body sanitization/noise filtering regressed:\n%s", out)
276
+ }
277
+ if got := utf8.RuneCountInString(out); got > githubOverviewRunes {
278
+ t.Fatalf("PR overview exceeded shared target: %d runes\n%s", got, out)
279
+ }
280
+ if got := atomic.LoadInt32(&timelineCalls); got != 1 {
281
+ t.Fatalf("overview should fetch one timeline page, got %d", got)
282
+ }
283
+ if got := atomic.LoadInt32(&reviewCalls); got != 1 {
284
+ t.Fatalf("overview should fetch one review page, got %d", got)
285
+ }
286
+ if got := atomic.LoadInt32(&reviewCommentCalls); got != 1 {
287
+ t.Fatalf("overview should fetch one review-comment page, got %d", got)
288
+ }
289
+ if got := atomic.LoadInt32(&graphqlCalls); got != 0 {
290
+ t.Fatalf("anonymous PR made %d GraphQL calls", got)
291
+ }
292
+ }
293
+
294
+ func TestPullThreadGroupingFollowsReplyRootsAcrossReviews(t *testing.T) {
295
+ root := int64(10)
296
+ reply := int64(11)
297
+ comments := []githubPullReviewComment{
298
+ {ID: 12, InReplyToID: &reply, CreatedAt: "2026-08-03T00:00:00Z", Body: stringPtr("nested")},
299
+ {ID: 10, CreatedAt: "2026-08-01T00:00:00Z", Body: stringPtr("root")},
300
+ {ID: 11, InReplyToID: &root, CreatedAt: "2026-08-02T00:00:00Z", Body: stringPtr("reply")},
301
+ }
302
+ threads := groupGitHubPullThreads(comments)
303
+ if len(threads) != 1 || threads[0].Root.ID != 10 || len(threads[0].Replies) != 2 || threads[0].Replies[0].ID != 11 || threads[0].Replies[1].ID != 12 {
304
+ t.Fatalf("unexpected thread grouping: %#v", threads)
305
+ }
306
+ }
307
+
308
+ func TestReadGitHubPullRequestGraphQLEnrichesThreadState(t *testing.T) {
309
+ var graphQLCalls int32
310
+ var server *httptest.Server
311
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
312
+ w.Header().Set("Content-Type", "application/json")
313
+ switch r.URL.Path {
314
+ case "/repos/o/r/pulls/42":
315
+ _, _ = io.WriteString(w, `{"number":42,"state":"open","title":"x","html_url":"https://github.com/o/r/pull/42"}`)
316
+ case "/repos/o/r/issues/42":
317
+ _, _ = io.WriteString(w, `{"id":987,"number":42,"title":"x","pull_request":{"html_url":"https://github.com/o/r/pull/42"}}`)
318
+ case "/repos/o/r/issues/42/timeline", "/repos/o/r/pulls/42/reviews":
319
+ _, _ = io.WriteString(w, `[]`)
320
+ case "/repos/o/r/pulls/42/comments":
321
+ _, _ = io.WriteString(w, `[{"id":100,"path":"a.go","line":5,"side":"RIGHT","body":"thread","created_at":"2026-08-01T00:00:00Z"}]`)
322
+ case "/graphql":
323
+ atomic.AddInt32(&graphQLCalls, 1)
324
+ if got := r.Header.Get("Authorization"); got != "Bearer fake-token" {
325
+ t.Errorf("GraphQL auth mismatch: %q", got)
326
+ }
327
+ if r.Header.Get("X-GitHub-Api-Version") != "" {
328
+ t.Errorf("GraphQL should not inherit REST version header")
329
+ }
330
+ var payload map[string]any
331
+ _ = json.NewDecoder(r.Body).Decode(&payload)
332
+ if !strings.Contains(fmt.Sprint(payload["query"]), "reviewThreads") {
333
+ t.Errorf("unexpected GraphQL query: %#v", payload)
334
+ }
335
+ _, _ = io.WriteString(w, `{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"isResolved":true,"isOutdated":true,"resolvedBy":{"login":"resolver"},"comments":{"nodes":[{"fullDatabaseId":"100"}]}}],"pageInfo":{"hasNextPage":true,"endCursor":"must-not-follow"}}}}}}`)
336
+ default:
337
+ t.Fatalf("unexpected request %s", r.URL.Path)
338
+ }
339
+ }))
340
+ defer server.Close()
341
+ client := testGitHubClient(server.URL, server.URL, "fake-token")
342
+ out, err := readGitHubPullRequest(context.Background(), client, parseGitHubTarget("https://github.com/o/r/pull/42"))
343
+ if err != nil {
344
+ t.Fatal(err)
345
+ }
346
+ if !strings.Contains(out, "State: resolved · outdated · by @resolver") || !strings.Contains(out, "thread_state_enrichment: partial_provider_page") || !strings.Contains(out, "first GraphQL page") || strings.Contains(out, "unavailable_without_auth") {
347
+ t.Fatalf("GraphQL thread state missing:\n%s", out)
348
+ }
349
+ if atomic.LoadInt32(&graphQLCalls) != 1 {
350
+ t.Fatalf("expected one GraphQL call")
351
+ }
352
+ }
353
+
354
+ func TestPullOverviewReservesOrdinaryCommentSelectorsAheadOfStateEvents(t *testing.T) {
355
+ timeline := []githubTimelineEvent{}
356
+ for i := 0; i < 12; i++ {
357
+ timeline = append(timeline, githubTimelineEvent{Event: "head_ref_force_pushed", CommitID: fmt.Sprintf("%040x", i+1), CreatedAt: fmt.Sprintf("2026-08-01T00:%02d:00Z", i)})
358
+ }
359
+ for i := 0; i < 3; i++ {
360
+ id := int64(900 + i)
361
+ body := fmt.Sprintf("comment-%d", id)
362
+ timeline = append(timeline, githubTimelineEvent{ID: id, Event: "commented", Body: &body, HTMLURL: fmt.Sprintf("https://github.com/o/r/pull/42#issuecomment-%d", id), User: githubUser{Login: "u"}, CreatedAt: fmt.Sprintf("2026-08-02T00:%02d:00Z", i)})
363
+ }
364
+ pr := githubPullRequest{Number: 42, State: "open", Title: "mixed timeline", HTMLURL: "https://github.com/o/r/pull/42", Comments: 3}
365
+ out := renderGitHubPullRequest(&GitHubTarget{Owner: "o", Repo: "r", Number: 42}, pr, 987, timeline, nil, nil, githubPullOverviewAvailability{}, "", false)
366
+ for i := 0; i < 3; i++ {
367
+ id := 900 + i
368
+ if !strings.Contains(out, fmt.Sprintf("Comment `%d`", id)) || !strings.Contains(out, fmt.Sprintf("#issuecomment-%d", id)) {
369
+ t.Fatalf("ordinary comment %d was crowded out by state events:\n%s", id, out)
370
+ }
371
+ }
372
+ if !strings.Contains(out, "conversation_comments_indexed: 3") || !strings.Contains(out, "conversation_events_omitted:") {
373
+ t.Fatalf("split conversation/event accounting missing:\n%s", out)
374
+ }
375
+ }
376
+
377
+ func TestPullGraphQLEnrichmentFailurePreservesRESTCore(t *testing.T) {
378
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
379
+ w.Header().Set("Content-Type", "application/json")
380
+ switch r.URL.Path {
381
+ case "/repos/o/r/pulls/42":
382
+ _, _ = io.WriteString(w, `{"number":42,"state":"open","title":"x"}`)
383
+ case "/repos/o/r/issues/42":
384
+ _, _ = io.WriteString(w, `{"id":987,"number":42,"title":"x","pull_request":{"html_url":"https://github.com/o/r/pull/42"}}`)
385
+ case "/repos/o/r/issues/42/timeline", "/repos/o/r/pulls/42/reviews":
386
+ _, _ = io.WriteString(w, `[]`)
387
+ case "/repos/o/r/pulls/42/comments":
388
+ _, _ = io.WriteString(w, `[{"id":100,"path":"a.go","line":5,"body":"REST survives"}]`)
389
+ case "/graphql":
390
+ w.WriteHeader(http.StatusInternalServerError)
391
+ _, _ = io.WriteString(w, `{"message":"private graph detail must not surface"}`)
392
+ default:
393
+ t.Fatalf("unexpected request %s", r.URL.Path)
394
+ }
395
+ }))
396
+ defer server.Close()
397
+ out, err := readGitHubPullRequest(context.Background(), testGitHubClient(server.URL, server.URL, "fake-token"), parseGitHubTarget("https://github.com/o/r/pull/42"))
398
+ if err != nil {
399
+ t.Fatalf("optional GraphQL failure destroyed REST core: %v", err)
400
+ }
401
+ if !strings.Contains(out, "REST survives") || !strings.Contains(out, "enrichment was unavailable") || strings.Contains(out, "private graph detail") {
402
+ t.Fatalf("graceful enrichment failure incorrect:\n%s", out)
403
+ }
404
+ }
405
+
406
+ func TestPullBodySelectorUsesIssueSideIDAndOneProviderRead(t *testing.T) {
407
+ longBody := "Exact Pull Request description\n\n" + strings.Repeat("selected PR body stays complete even when the root would preview it\n", 180)
408
+ var requests int32
409
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
410
+ atomic.AddInt32(&requests, 1)
411
+ if r.URL.Path != "/repos/o/r/issues/42" {
412
+ t.Fatalf("PR body selector fetched unrelated endpoint: %s", r.URL.Path)
413
+ }
414
+ w.Header().Set("Content-Type", "application/json")
415
+ _ = json.NewEncoder(w).Encode(map[string]any{
416
+ "id": 987, "number": 42, "title": "Body selector", "body": longBody,
417
+ "html_url": "https://github.com/o/r/pull/42", "user": map[string]any{"login": "alice"},
418
+ "pull_request": map[string]any{"html_url": "https://github.com/o/r/pull/42"},
419
+ })
420
+ }))
421
+ defer server.Close()
422
+
423
+ out, err := readGitHubPullRequest(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/pull/42#issue-987"))
424
+ if err != nil {
425
+ t.Fatal(err)
426
+ }
427
+ if got := atomic.LoadInt32(&requests); got != 1 {
428
+ t.Fatalf("exact PR body selector should use one Issue-side provider read, got %d", got)
429
+ }
430
+ for _, want := range []string{"pull_request: 42", "issue_id: 987", "url: \"https://github.com/o/r/pull/42#issue-987\"", strings.TrimSpace(longBody)} {
431
+ if !strings.Contains(out, want) {
432
+ t.Fatalf("exact PR body selector missing %q:\n%s", want, out)
433
+ }
434
+ }
435
+ if strings.Contains(out, "overview: true") || strings.Contains(out, "Conversation index") || strings.Contains(out, "preview truncated") {
436
+ t.Fatalf("exact PR body selector expanded or truncated parent context:\n%s", out)
437
+ }
438
+
439
+ atomic.StoreInt32(&requests, 0)
440
+ _, err = readGitHubPullRequest(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/pull/42#issue-123"))
441
+ if err == nil || !strings.Contains(err.Error(), "does not belong") || !strings.Contains(err.Error(), "Issue-side id 987") {
442
+ t.Fatalf("mismatched PR body selector was not rejected truthfully: %v", err)
443
+ }
444
+ if got := atomic.LoadInt32(&requests); got != 1 {
445
+ t.Fatalf("mismatched PR body selector should stop after one identity read, got %d", got)
446
+ }
447
+ }
448
+
449
+ func TestLargePullRootOverviewIsBoundedAndEveryIndexedChildHasSelector(t *testing.T) {
450
+ body := "Opening PR description context.\n\n" + strings.Repeat("Description paragraph that should be previewed safely.\n\n", 30) + "```go\n" + strings.Repeat("fmt.Println(\"inside PR fence\")\n", 70) + "```\n\nTAIL PR BODY MUST NOT APPEAR"
451
+ timeline := make([]githubTimelineEvent, 0, 20)
452
+ reviews := make([]githubPullReview, 0, 12)
453
+ threads := make([]githubPullThread, 0, 12)
454
+ for i := 1; i <= 20; i++ {
455
+ comment := fmt.Sprintf("Conversation %02d marker. %s TAIL CONVERSATION %02d MUST NOT APPEAR", i, strings.Repeat("comment detail. ", 50), i)
456
+ timeline = append(timeline, githubTimelineEvent{
457
+ ID: int64(i), Event: "commented", Body: stringPtr(comment), HTMLURL: fmt.Sprintf("https://github.com/o/r/pull/42#issuecomment-%d", i),
458
+ User: githubUser{Login: fmt.Sprintf("commenter-%02d", i)}, CreatedAt: fmt.Sprintf("2026-08-%02dT00:00:00Z", (i%28)+1),
459
+ })
460
+ }
461
+ for i := 1; i <= 12; i++ {
462
+ reviewID := int64(1000 + i)
463
+ reviewBody := fmt.Sprintf("Review %02d marker. %s TAIL REVIEW %02d MUST NOT APPEAR", i, strings.Repeat("review detail. ", 50), i)
464
+ reviews = append(reviews, githubPullReview{
465
+ ID: reviewID, State: "APPROVED", Body: stringPtr(reviewBody), HTMLURL: fmt.Sprintf("https://github.com/o/r/pull/42#pullrequestreview-%d", reviewID),
466
+ User: githubUser{Login: fmt.Sprintf("reviewer-%02d", i)}, SubmittedAt: fmt.Sprintf("2026-08-%02dT01:00:00Z", (i%28)+1),
467
+ })
468
+ threadID := int64(2000 + i)
469
+ line := i
470
+ threadBody := fmt.Sprintf("Thread %02d marker. %s TAIL THREAD %02d MUST NOT APPEAR", i, strings.Repeat("thread detail. ", 50), i)
471
+ threads = append(threads, githubPullThread{Root: githubPullReviewComment{
472
+ ID: threadID, Body: stringPtr(threadBody), HTMLURL: fmt.Sprintf("https://github.com/o/r/pull/42#discussion_r%d", threadID), Path: "internal/large.go", Line: &line, Side: "RIGHT",
473
+ User: githubUser{Login: fmt.Sprintf("threader-%02d", i)}, CreatedAt: fmt.Sprintf("2026-08-%02dT02:00:00Z", (i%28)+1),
474
+ }})
475
+ }
476
+ pr := githubPullRequest{
477
+ ID: 123, Number: 42, State: "open", Title: "Pathologically large PR", Body: stringPtr(body), HTMLURL: "https://github.com/o/r/pull/42",
478
+ User: githubUser{Login: "author"}, Comments: 25, ReviewComments: 30, Commits: 9, ChangedFiles: 18, Additions: 1200, Deletions: 450,
479
+ Head: githubPullRef{Label: "fork:feature"}, Base: githubPullRef{Label: "o:main"},
480
+ }
481
+ availability := githubPullOverviewAvailability{TimelineProviderMore: true, ReviewsProviderMore: true, ReviewCommentsProviderMore: true, ReviewCommentsReturned: 24}
482
+ out := renderGitHubPullRequest(&GitHubTarget{Owner: "o", Repo: "r", Number: 42}, pr, 987, timeline, reviews, threads, availability, "", false)
483
+ if got := utf8.RuneCountInString(out); got > githubOverviewRunes {
484
+ t.Fatalf("large PR overview exceeded shared target: %d runes\n%s", got, out)
485
+ }
486
+ for _, want := range []string{
487
+ "overview: true", "pull_request_id: 123", "issue_id: 987", "Opening PR description context.", "Description preview locally truncated",
488
+ "Full description: https://github.com/o/r/pull/42#issue-987", "locally omitted from this overview",
489
+ "conversation_provider_more_available: true", "reviews_provider_more_available: true", "review_comments_provider_more_available: true",
490
+ "Conversation 01 marker", "Review 01 marker", "Thread 01 marker", "/pull/42/files", "/pull/42/commits", "/pull/42/checks",
491
+ } {
492
+ if !strings.Contains(out, want) {
493
+ t.Fatalf("large PR overview missing %q:\n%s", want, out)
494
+ }
495
+ }
496
+ for _, forbidden := range []string{"TAIL PR BODY MUST NOT APPEAR", "TAIL CONVERSATION 01 MUST NOT APPEAR", "TAIL REVIEW 01 MUST NOT APPEAR", "TAIL THREAD 01 MUST NOT APPEAR"} {
497
+ if strings.Contains(out, forbidden) {
498
+ t.Fatalf("large PR overview leaked full subordinate content %q:\n%s", forbidden, out)
499
+ }
500
+ }
501
+ assertPullOverviewChildSelectors(t, out)
502
+ previewStart := strings.Index(out, "## Description preview\n\n")
503
+ previewEnd := strings.Index(out, "\n\n> Description preview locally truncated")
504
+ if previewStart < 0 || previewEnd <= previewStart {
505
+ t.Fatalf("could not locate PR description preview boundaries:\n%s", out)
506
+ }
507
+ preview := out[previewStart+len("## Description preview\n\n") : previewEnd]
508
+ if strings.Count(preview, "```")%2 != 0 {
509
+ t.Fatalf("PR description preview ended inside a Markdown fence:\n%s", preview)
510
+ }
511
+ }
512
+
513
+ func assertPullOverviewChildSelectors(t *testing.T, out string) {
514
+ t.Helper()
515
+ lines := strings.Split(out, "\n")
516
+ for i, line := range lines {
517
+ var fragmentPrefix string
518
+ switch {
519
+ case strings.HasPrefix(line, "### Comment `"):
520
+ fragmentPrefix = "#issuecomment-"
521
+ case strings.HasPrefix(line, "### Review `"):
522
+ fragmentPrefix = "#pullrequestreview-"
523
+ case strings.HasPrefix(line, "### Thread `"):
524
+ fragmentPrefix = "#discussion_r"
525
+ default:
526
+ continue
527
+ }
528
+ parts := strings.Split(line, "`")
529
+ if len(parts) < 3 || parts[1] == "" {
530
+ t.Fatalf("indexed child lacks stable ID: %q", line)
531
+ }
532
+ want := fragmentPrefix + parts[1]
533
+ found := false
534
+ for j := i + 1; j < len(lines) && !strings.HasPrefix(lines[j], "### "); j++ {
535
+ if strings.HasPrefix(lines[j], "Selector: ") && strings.HasSuffix(lines[j], want) {
536
+ found = true
537
+ break
538
+ }
539
+ }
540
+ if !found {
541
+ t.Fatalf("indexed child %q lacks matching exact selector %q:\n%s", line, want, out)
542
+ }
543
+ }
544
+ }
545
+
546
+ func TestPullDiscussionSelectorReturnsOnlySelectedThread(t *testing.T) {
547
+ var requests int32
548
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
549
+ atomic.AddInt32(&requests, 1)
550
+ w.Header().Set("Content-Type", "application/json")
551
+ switch r.URL.Path {
552
+ case "/repos/o/r/pulls/comments/101":
553
+ _, _ = io.WriteString(w, `{"id":101,"pull_request_url":"https://api.github.com/repos/o/r/pulls/42","in_reply_to_id":100,"path":"a.go","line":9,"body":"Selected reply","created_at":"2026-08-02T00:00:00Z"}`)
554
+ case "/repos/o/r/pulls/42/comments":
555
+ _, _ = io.WriteString(w, `[
556
+ {"id":100,"path":"a.go","line":9,"body":"Thread root","created_at":"2026-08-01T00:00:00Z"},
557
+ {"id":101,"in_reply_to_id":100,"path":"a.go","line":9,"body":"Selected reply","created_at":"2026-08-02T00:00:00Z"},
558
+ {"id":200,"path":"b.go","line":2,"body":"Unrelated thread","created_at":"2026-08-01T00:00:00Z"}
559
+ ]`)
560
+ default:
561
+ t.Fatalf("selector fetched unrelated endpoint %s", r.URL.Path)
562
+ }
563
+ }))
564
+ defer server.Close()
565
+ out, err := readGitHubPullRequest(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/pull/42#discussion_r101"))
566
+ if err != nil {
567
+ t.Fatal(err)
568
+ }
569
+ if !strings.Contains(out, "Thread root") || !strings.Contains(out, "Selected reply") || !strings.Contains(out, "selected") || strings.Contains(out, "Unrelated thread") {
570
+ t.Fatalf("exact discussion selector not scoped:\n%s", out)
571
+ }
572
+ if got := atomic.LoadInt32(&requests); got != 2 {
573
+ t.Fatalf("expected targeted comment + review-comment list only, got %d requests", got)
574
+ }
575
+ }
576
+
577
+ func TestPullReviewSelectorReturnsReviewAndItsComments(t *testing.T) {
578
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
579
+ w.Header().Set("Content-Type", "application/json")
580
+ switch r.URL.Path {
581
+ case "/repos/o/r/pulls/42/reviews/500":
582
+ _, _ = io.WriteString(w, `{"id":500,"state":"APPROVED","body":"Selected review","user":{"login":"reviewer"},"submitted_at":"2026-08-01T00:00:00Z"}`)
583
+ case "/repos/o/r/pulls/42/reviews/500/comments":
584
+ _, _ = io.WriteString(w, `[{"id":100,"pull_request_review_id":500,"path":"a.go","line":3,"body":"Review comment"}]`)
585
+ default:
586
+ t.Fatalf("review selector fetched unrelated endpoint %s", r.URL.Path)
587
+ }
588
+ }))
589
+ defer server.Close()
590
+ out, err := readGitHubPullRequest(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/pull/42#pullrequestreview-500"))
591
+ if err != nil {
592
+ t.Fatal(err)
593
+ }
594
+ if !strings.Contains(out, "review_id: 500") || !strings.Contains(out, "Selected review") || !strings.Contains(out, "Review comment") {
595
+ t.Fatalf("review selector missing context:\n%s", out)
596
+ }
597
+ }
598
+
599
+ func TestPullIssueCommentSelectorUsesSharedIdentity(t *testing.T) {
600
+ var requests int32
601
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
602
+ atomic.AddInt32(&requests, 1)
603
+ if r.URL.Path != "/repos/o/r/issues/comments/99" {
604
+ t.Fatalf("normal PR comment selector fetched %s", r.URL.Path)
605
+ }
606
+ w.Header().Set("Content-Type", "application/json")
607
+ _, _ = io.WriteString(w, `{"id":99,"issue_url":"https://api.github.com/repos/o/r/issues/42","body":"PR conversation comment","user":{"login":"u"}}`)
608
+ }))
609
+ defer server.Close()
610
+ out, err := readGitHubPullRequest(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/pull/42#issuecomment-99"))
611
+ if err != nil {
612
+ t.Fatal(err)
613
+ }
614
+ if atomic.LoadInt32(&requests) != 1 || !strings.Contains(out, "pull_request: 42") || !strings.Contains(out, "Comment on Pull Request") || !strings.Contains(out, "PR conversation comment") {
615
+ t.Fatalf("shared normal-comment selector regressed:\n%s", out)
616
+ }
617
+ }
618
+
619
+ func TestPullConversationProviderFailureStaysNative(t *testing.T) {
620
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
621
+ w.WriteHeader(http.StatusNotFound)
622
+ _, _ = io.WriteString(w, `{"message":"private detail"}`)
623
+ }))
624
+ defer server.Close()
625
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/o/r/pull/42"), testGitHubClient(server.URL, server.URL, ""))
626
+ if result.Outcome != GitHubNativeFailure || result.Err == nil || !strings.Contains(result.Err.Error(), "may be private") || strings.Contains(result.Err.Error(), "private detail") {
627
+ t.Fatalf("unexpected PR native failure: %#v", result)
628
+ }
629
+ }
630
+
631
+ func TestGraphQLRequiresTokenAndDoesNotLeakProviderErrors(t *testing.T) {
632
+ client := testGitHubClient("https://api.github.test", "https://raw.github.test", "")
633
+ if err := client.GraphQL(context.Background(), "query{viewer{login}}", nil, &struct{}{}); err == nil || !strings.Contains(err.Error(), "authentication is required") {
634
+ t.Fatalf("expected no-token GraphQL auth error, got %v", err)
635
+ }
636
+
637
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
638
+ w.Header().Set("Content-Type", "application/json")
639
+ _, _ = io.WriteString(w, `{"errors":[{"message":"secret private graph error"}]}`)
640
+ }))
641
+ defer server.Close()
642
+ err := testGitHubClient(server.URL, server.URL, "token-value").GraphQL(context.Background(), "query{x}", nil, &struct{}{})
643
+ if err == nil || strings.Contains(err.Error(), "secret private graph error") || strings.Contains(err.Error(), "token-value") {
644
+ t.Fatalf("GraphQL provider detail leaked: %v", err)
645
+ }
646
+ }
647
+
648
+ func TestFetchGitHubPullThreadStatesPaginatesGraphQL(t *testing.T) {
649
+ var calls int32
650
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
651
+ call := atomic.AddInt32(&calls, 1)
652
+ w.Header().Set("Content-Type", "application/json")
653
+ if call == 1 {
654
+ _, _ = io.WriteString(w, `{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"isResolved":false,"isOutdated":false,"comments":{"nodes":[{"fullDatabaseId":"100"}]}}],"pageInfo":{"hasNextPage":true,"endCursor":"cursor-1"}}}}}}`)
655
+ return
656
+ }
657
+ var payload struct {
658
+ Variables map[string]any `json:"variables"`
659
+ }
660
+ _ = json.NewDecoder(r.Body).Decode(&payload)
661
+ if payload.Variables["after"] != "cursor-1" {
662
+ t.Errorf("GraphQL pagination cursor lost: %#v", payload.Variables)
663
+ }
664
+ _, _ = io.WriteString(w, `{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"isResolved":true,"isOutdated":true,"comments":{"nodes":[{"fullDatabaseId":"200"}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}}`)
665
+ }))
666
+ defer server.Close()
667
+ states, err := fetchGitHubPullThreadStates(context.Background(), testGitHubClient(server.URL, server.URL, "token"), &GitHubTarget{Owner: "o", Repo: "r", Number: 42})
668
+ if err != nil {
669
+ t.Fatal(err)
670
+ }
671
+ if len(states) != 2 || states[100].Resolved || !states[200].Resolved || !states[200].Outdated || atomic.LoadInt32(&calls) != 2 {
672
+ t.Fatalf("unexpected GraphQL states/calls: %#v calls=%d", states, calls)
673
+ }
674
+ }
675
+
676
+ func TestPullDisplayState(t *testing.T) {
677
+ for _, tt := range []struct {
678
+ pr githubPullRequest
679
+ want string
680
+ }{
681
+ {pr: githubPullRequest{State: "open", Draft: true}, want: "draft"},
682
+ {pr: githubPullRequest{State: "closed", Merged: true}, want: "merged"},
683
+ {pr: githubPullRequest{State: "closed", MergedAt: "2026-01-01T00:00:00Z"}, want: "merged"},
684
+ {pr: githubPullRequest{State: "closed"}, want: "closed"},
685
+ } {
686
+ if got := pullDisplayState(tt.pr); got != tt.want {
687
+ t.Fatalf("pullDisplayState(%#v)=%q want %q", tt.pr, got, tt.want)
688
+ }
689
+ }
690
+ }
691
+
692
+ func TestPullTimelineSkipsReviewDuplicatesButKeepsMeaningfulEvents(t *testing.T) {
693
+ if line, ok := renderPullTimelineState(githubTimelineEvent{Event: "reviewed", ID: 5}); ok || line != "" {
694
+ t.Fatalf("reviewed event should defer to review endpoint")
695
+ }
696
+ if line, ok := renderPullTimelineState(githubTimelineEvent{Event: "committed", SHA: "abcdef1234567890", Message: "subject\nbody"}); !ok || !strings.Contains(line, "`abcdef123456`") || !strings.Contains(line, "subject") || strings.Contains(line, "body") {
697
+ t.Fatalf("commit timeline rendering incorrect: %q", line)
698
+ }
699
+ if line, ok := renderPullTimelineState(githubTimelineEvent{Event: "renamed", Rename: &struct {
700
+ From string `json:"from"`
701
+ To string `json:"to"`
702
+ }{From: "old", To: "new"}}); !ok || !strings.Contains(line, "renamed the Pull Request") {
703
+ t.Fatalf("PR rename inherited Issue wording: %q", line)
704
+ }
705
+ tevent := githubTimelineEvent{Event: "review_requested"}
706
+ tevent.RequestedTeam = &struct {
707
+ Name string `json:"name"`
708
+ Slug string `json:"slug"`
709
+ }{Name: "code reviewers", Slug: "code-reviewers"}
710
+ if line, ok := renderPullTimelineState(tevent); !ok || !strings.Contains(line, "team `code reviewers`") {
711
+ t.Fatalf("team review request lost identity: %q", line)
712
+ }
713
+ }
714
+
715
+ func stringPtr(s string) *string { return &s }