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,429 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "net/url"
9
+ "strconv"
10
+ "strings"
11
+ )
12
+
13
+ var githubPackageTypes = map[string]struct{}{
14
+ "container": {}, "docker": {}, "maven": {}, "npm": {}, "nuget": {}, "rubygems": {},
15
+ }
16
+
17
+ type githubPackage struct {
18
+ ID int64 `json:"id"`
19
+ Name string `json:"name"`
20
+ PackageType string `json:"package_type"`
21
+ HTMLURL string `json:"html_url"`
22
+ Visibility string `json:"visibility"`
23
+ Description *string `json:"description"`
24
+ VersionCount int `json:"version_count"`
25
+ CreatedAt string `json:"created_at"`
26
+ UpdatedAt string `json:"updated_at"`
27
+ Owner githubUser `json:"owner"`
28
+ Repository *struct {
29
+ FullName string `json:"full_name"`
30
+ HTMLURL string `json:"html_url"`
31
+ } `json:"repository"`
32
+ }
33
+
34
+ type githubPackageVersion struct {
35
+ ID int64 `json:"id"`
36
+ Name string `json:"name"`
37
+ URL string `json:"url"`
38
+ PackageHTMLURL string `json:"package_html_url"`
39
+ CreatedAt string `json:"created_at"`
40
+ UpdatedAt string `json:"updated_at"`
41
+ Metadata struct {
42
+ PackageType string `json:"package_type"`
43
+ Container struct {
44
+ Tags []string `json:"tags"`
45
+ } `json:"container"`
46
+ } `json:"metadata"`
47
+ }
48
+
49
+ type githubProjectV2 struct {
50
+ ID string
51
+ Number int
52
+ Title string
53
+ ShortDescription string
54
+ URL string
55
+ Public bool
56
+ Closed bool
57
+ CreatedAt string
58
+ UpdatedAt string
59
+ Items []githubProjectV2Item
60
+ MoreItems bool
61
+ }
62
+
63
+ type githubProjectV2Item struct {
64
+ ID string
65
+ Type string
66
+ Title string
67
+ URL string
68
+ State string
69
+ Repository string
70
+ Number int
71
+ Body string
72
+ }
73
+
74
+ func isSupportedGitHubPackageType(packageType string) bool {
75
+ _, ok := githubPackageTypes[packageType]
76
+ return ok
77
+ }
78
+
79
+ func readGitHubPackage(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
80
+ if target.Fragment != "" {
81
+ return "", fmt.Errorf("GitHub Package fragment %q is not a supported native selector", target.Fragment)
82
+ }
83
+ for key := range target.Query {
84
+ if key != "page" {
85
+ return "", fmt.Errorf("GitHub Package query parameter %q is not supported by the native reader", key)
86
+ }
87
+ }
88
+ page := target.Query.Get("page")
89
+ if page != "" {
90
+ n, err := strconv.Atoi(page)
91
+ if err != nil || n <= 0 {
92
+ return "", fmt.Errorf("invalid GitHub Package page %q", page)
93
+ }
94
+ }
95
+ if len(target.Tail) != 2 {
96
+ return "", fmt.Errorf("GitHub Package target is missing scope/type identity")
97
+ }
98
+ scope, packageType := target.Tail[0], target.Tail[1]
99
+ base, err := githubPackageAPIBase(scope, target.Owner, packageType, target.Name)
100
+ if err != nil {
101
+ return "", err
102
+ }
103
+ resp, err := client.REST(ctx, http.MethodGet, base, "application/vnd.github+json")
104
+ if err != nil {
105
+ return "", err
106
+ }
107
+ var pkg githubPackage
108
+ if err := json.Unmarshal(resp.Body, &pkg); err != nil {
109
+ return "", fmt.Errorf("decode GitHub Package: %w", err)
110
+ }
111
+ q := url.Values{"per_page": []string{strconv.Itoa(githubPageableListSize)}}
112
+ if page != "" {
113
+ q.Set("page", page)
114
+ }
115
+ versionsResp, err := client.REST(ctx, http.MethodGet, base+"/versions?"+q.Encode(), "application/vnd.github+json")
116
+ if err != nil {
117
+ return "", err
118
+ }
119
+ var versions []githubPackageVersion
120
+ if err := json.Unmarshal(versionsResp.Body, &versions); err != nil {
121
+ return "", fmt.Errorf("decode GitHub Package versions: %w", err)
122
+ }
123
+ return renderGitHubPackage(target, pkg, versions, versionsResp.Links()), nil
124
+ }
125
+
126
+ func githubPackageAPIBase(scope, owner, packageType, name string) (string, error) {
127
+ prefix := ""
128
+ switch scope {
129
+ case "org":
130
+ prefix = "/orgs/" + url.PathEscape(owner)
131
+ case "user":
132
+ prefix = "/users/" + url.PathEscape(owner)
133
+ default:
134
+ return "", fmt.Errorf("unsupported GitHub Package scope %q", scope)
135
+ }
136
+ return fmt.Sprintf("%s/packages/%s/%s", prefix, url.PathEscape(packageType), url.PathEscape(name)), nil
137
+ }
138
+
139
+ func renderGitHubPackage(target *GitHubTarget, pkg githubPackage, versions []githubPackageVersion, links GitHubLinkRelations) string {
140
+ return renderGitHubPackageWithLimit(target, pkg, versions, links, len(versions))
141
+ }
142
+
143
+ func renderGitHubPackageWithLimit(target *GitHubTarget, pkg githubPackage, versions []githubPackageVersion, links GitHubLinkRelations, limit int) string {
144
+ limit = minInt(limit, len(versions))
145
+ page := target.Query.Get("page")
146
+ if page == "" {
147
+ page = "1"
148
+ }
149
+ lines := []string{
150
+ "---",
151
+ "owner: " + yamlScalar(target.Owner),
152
+ "package: " + yamlScalar(pkg.Name),
153
+ "package_type: " + yamlScalar(pkg.PackageType),
154
+ "visibility: " + yamlScalar(pkg.Visibility),
155
+ fmt.Sprintf("version_count: %d", pkg.VersionCount),
156
+ "versions_page: " + page,
157
+ fmt.Sprintf("versions_returned: %d", len(versions)),
158
+ fmt.Sprintf("versions_indexed: %d", limit),
159
+ fmt.Sprintf("versions_local_omitted: %d", len(versions)-limit),
160
+ }
161
+ if pkg.Repository != nil && pkg.Repository.FullName != "" {
162
+ lines = append(lines, "repository: "+yamlScalar(pkg.Repository.FullName))
163
+ }
164
+ if pkg.CreatedAt != "" {
165
+ lines = append(lines, "created: "+yamlScalar(pkg.CreatedAt))
166
+ }
167
+ if pkg.UpdatedAt != "" {
168
+ lines = append(lines, "updated: "+yamlScalar(pkg.UpdatedAt))
169
+ }
170
+ if pkg.HTMLURL != "" {
171
+ lines = append(lines, "url: "+yamlScalar(pkg.HTMLURL))
172
+ }
173
+ lines = append(lines, "---", "", "# Package: "+pkg.Name, "")
174
+ if pkg.Description != nil && strings.TrimSpace(*pkg.Description) != "" {
175
+ preview, truncated := githubOverviewPreview(stripInvisibleHTMLComments(*pkg.Description), 1200)
176
+ lines = append(lines, preview, "")
177
+ if truncated && pkg.HTMLURL != "" {
178
+ lines = append(lines, "> Package description preview locally truncated. Full package: "+pkg.HTMLURL, "")
179
+ }
180
+ }
181
+ lines = append(lines, "## Versions", "")
182
+ if len(versions) == 0 {
183
+ lines = append(lines, "_No package versions returned on this page._")
184
+ }
185
+ for _, version := range versions[:limit] {
186
+ name := version.Name
187
+ if name == "" {
188
+ name = fmt.Sprintf("version %d", version.ID)
189
+ }
190
+ namePreview, truncated := githubOverviewInlinePreview(name, 120)
191
+ if truncated {
192
+ namePreview += "…"
193
+ }
194
+ line := "- " + namePreview
195
+ if len(version.Metadata.Container.Tags) > 0 {
196
+ tags := make([]string, 0, minInt(5, len(version.Metadata.Container.Tags)))
197
+ for _, tag := range version.Metadata.Container.Tags[:minInt(5, len(version.Metadata.Container.Tags))] {
198
+ tagPreview, tagTruncated := githubOverviewInlinePreview(tag, 80)
199
+ if tagTruncated {
200
+ tagPreview += "…"
201
+ }
202
+ tags = append(tags, tagPreview)
203
+ }
204
+ line += " — tags: " + githubOverviewLabelList(tags, 5)
205
+ if len(version.Metadata.Container.Tags) > 5 {
206
+ line += fmt.Sprintf(" (+%d more)", len(version.Metadata.Container.Tags)-5)
207
+ }
208
+ }
209
+ if version.UpdatedAt != "" {
210
+ line += " — updated " + version.UpdatedAt
211
+ }
212
+ if version.PackageHTMLURL != "" {
213
+ line += " — " + version.PackageHTMLURL
214
+ }
215
+ lines = append(lines, line)
216
+ }
217
+ if nav := renderGitHubUIPageNavigation(target, links); len(nav) > 0 {
218
+ lines = append(lines, "", "## Version navigation", "")
219
+ lines = append(lines, nav...)
220
+ }
221
+ return strings.TrimSpace(strings.Join(lines, "\n"))
222
+ }
223
+
224
+ func readGitHubProjectV2(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
225
+ if target.Fragment != "" || len(target.Query) > 0 {
226
+ return "", fmt.Errorf("GitHub Projects v2 query/fragment selectors are not part of the native project contract")
227
+ }
228
+ if len(target.Tail) != 1 || (target.Tail[0] != "org" && target.Tail[0] != "user") {
229
+ return "", fmt.Errorf("GitHub Projects v2 target is missing owner scope")
230
+ }
231
+ project, err := fetchGitHubProjectV2(ctx, client, target)
232
+ if err != nil {
233
+ return "", err
234
+ }
235
+ return renderGitHubProjectV2(target, project), nil
236
+ }
237
+
238
+ func fetchGitHubProjectV2(ctx context.Context, client *GitHubClient, target *GitHubTarget) (githubProjectV2, error) {
239
+ prefix := "/orgs/" + url.PathEscape(target.Owner)
240
+ if target.Tail[0] == "user" {
241
+ prefix = "/users/" + url.PathEscape(target.Owner)
242
+ }
243
+ base := fmt.Sprintf("%s/projectsV2/%d", prefix, target.Number)
244
+ projectResp, err := client.RESTPublic(ctx, http.MethodGet, base, "application/vnd.github+json")
245
+ if err != nil {
246
+ return githubProjectV2{}, err
247
+ }
248
+ var raw struct {
249
+ ID int64 `json:"id"`
250
+ Number int `json:"number"`
251
+ Title string `json:"title"`
252
+ Description *string `json:"description"`
253
+ ShortDescription *string `json:"short_description"`
254
+ Public bool `json:"public"`
255
+ State string `json:"state"`
256
+ CreatedAt string `json:"created_at"`
257
+ UpdatedAt string `json:"updated_at"`
258
+ }
259
+ if err := json.Unmarshal(projectResp.Body, &raw); err != nil {
260
+ return githubProjectV2{}, fmt.Errorf("decode GitHub Project v2: %w", err)
261
+ }
262
+ description := ""
263
+ if raw.ShortDescription != nil {
264
+ description = strings.TrimSpace(*raw.ShortDescription)
265
+ }
266
+ if description == "" && raw.Description != nil {
267
+ description = strings.TrimSpace(*raw.Description)
268
+ }
269
+ project := githubProjectV2{
270
+ ID: strconv.FormatInt(raw.ID, 10),
271
+ Number: raw.Number,
272
+ Title: raw.Title,
273
+ ShortDescription: description,
274
+ URL: target.OriginalURL,
275
+ Public: raw.Public,
276
+ Closed: strings.EqualFold(raw.State, "closed"),
277
+ CreatedAt: raw.CreatedAt,
278
+ UpdatedAt: raw.UpdatedAt,
279
+ }
280
+ itemsResp, err := client.RESTPublic(ctx, http.MethodGet, base+"/items?per_page=50", "application/vnd.github+json")
281
+ if err != nil {
282
+ return githubProjectV2{}, err
283
+ }
284
+ var rawItems []struct {
285
+ ID int64 `json:"id"`
286
+ ContentType string `json:"content_type"`
287
+ Content json.RawMessage `json:"content"`
288
+ }
289
+ if err := json.Unmarshal(itemsResp.Body, &rawItems); err != nil {
290
+ return githubProjectV2{}, fmt.Errorf("decode GitHub Project v2 items: %w", err)
291
+ }
292
+ project.MoreItems = strings.TrimSpace(itemsResp.Links()["next"]) != ""
293
+ for _, rawItem := range rawItems {
294
+ item := githubProjectV2Item{ID: strconv.FormatInt(rawItem.ID, 10), Type: rawItem.ContentType}
295
+ if len(rawItem.Content) > 0 && string(rawItem.Content) != "null" {
296
+ var content struct {
297
+ Number int `json:"number"`
298
+ Title string `json:"title"`
299
+ Body string `json:"body"`
300
+ State string `json:"state"`
301
+ HTMLURL string `json:"html_url"`
302
+ RepositoryURL string `json:"repository_url"`
303
+ Repository *struct {
304
+ FullName string `json:"full_name"`
305
+ NameWithOwner string `json:"nameWithOwner"`
306
+ } `json:"repository"`
307
+ }
308
+ if err := json.Unmarshal(rawItem.Content, &content); err != nil {
309
+ return githubProjectV2{}, fmt.Errorf("decode GitHub Project v2 item content: %w", err)
310
+ }
311
+ item.Title, item.URL, item.State, item.Number, item.Body = content.Title, content.HTMLURL, content.State, content.Number, content.Body
312
+ if content.Repository != nil {
313
+ item.Repository = content.Repository.FullName
314
+ if item.Repository == "" {
315
+ item.Repository = content.Repository.NameWithOwner
316
+ }
317
+ }
318
+ if item.Repository == "" && content.RepositoryURL != "" {
319
+ if parsed, parseErr := url.Parse(content.RepositoryURL); parseErr == nil {
320
+ parts := splitGitHubPath(parsed.Path)
321
+ if len(parts) >= 3 && parts[0] == "repos" {
322
+ item.Repository = parts[1] + "/" + parts[2]
323
+ }
324
+ }
325
+ }
326
+ }
327
+ project.Items = append(project.Items, item)
328
+ }
329
+ return project, nil
330
+ }
331
+
332
+ func renderGitHubProjectV2(target *GitHubTarget, project githubProjectV2) string {
333
+ return githubBoundedOverviewList(len(project.Items), 20, func(limit int) string {
334
+ return renderGitHubProjectV2WithLimit(target, project, limit)
335
+ })
336
+ }
337
+
338
+ func renderGitHubProjectV2WithLimit(target *GitHubTarget, project githubProjectV2, limit int) string {
339
+ limit = minInt(limit, len(project.Items))
340
+ titlePreview, titleTruncated := githubOverviewInlinePreview(project.Title, 180)
341
+ if titleTruncated {
342
+ titlePreview += "…"
343
+ }
344
+ lines := []string{
345
+ "---",
346
+ "owner: " + yamlScalar(target.Owner),
347
+ fmt.Sprintf("project: %d", project.Number),
348
+ "title: " + yamlScalar(titlePreview),
349
+ fmt.Sprintf("public: %t", project.Public),
350
+ fmt.Sprintf("closed: %t", project.Closed),
351
+ fmt.Sprintf("items_returned: %d", len(project.Items)),
352
+ fmt.Sprintf("items_indexed: %d", limit),
353
+ fmt.Sprintf("items_local_omitted: %d", len(project.Items)-limit),
354
+ fmt.Sprintf("more_items_available: %t", project.MoreItems),
355
+ }
356
+ if project.CreatedAt != "" {
357
+ lines = append(lines, "created: "+yamlScalar(project.CreatedAt))
358
+ }
359
+ if project.UpdatedAt != "" {
360
+ lines = append(lines, "updated: "+yamlScalar(project.UpdatedAt))
361
+ }
362
+ if project.URL != "" {
363
+ lines = append(lines, "url: "+yamlScalar(project.URL))
364
+ }
365
+ lines = append(lines, "---", "", "# "+titlePreview, "")
366
+ if strings.TrimSpace(project.ShortDescription) != "" {
367
+ preview, truncated := githubOverviewPreview(stripInvisibleHTMLComments(project.ShortDescription), 1000)
368
+ lines = append(lines, preview, "")
369
+ if truncated && project.URL != "" {
370
+ lines = append(lines, "> Project description preview locally truncated. Full project: "+project.URL, "")
371
+ }
372
+ }
373
+ lines = append(lines, "## Items", "")
374
+ if len(project.Items) == 0 {
375
+ lines = append(lines, "_No Project items returned._")
376
+ }
377
+ for _, item := range project.Items[:limit] {
378
+ label := item.Title
379
+ if label == "" {
380
+ label = item.Type
381
+ }
382
+ line := "- "
383
+ labelPreview, truncated := githubOverviewInlinePreview(label, 140)
384
+ if truncated {
385
+ labelPreview += "…"
386
+ }
387
+ if item.URL != "" {
388
+ line += "[" + escapeMarkdownLinkText(labelPreview) + "](" + item.URL + ")"
389
+ } else {
390
+ line += labelPreview
391
+ }
392
+ meta := []string{}
393
+ if item.Repository != "" {
394
+ repository, truncated := githubOverviewInlinePreview(item.Repository, 120)
395
+ if truncated {
396
+ repository += "…"
397
+ }
398
+ meta = append(meta, repository)
399
+ }
400
+ if item.Number > 0 {
401
+ meta = append(meta, fmt.Sprintf("#%d", item.Number))
402
+ }
403
+ if item.State != "" {
404
+ state, truncated := githubOverviewInlinePreview(item.State, 40)
405
+ if truncated {
406
+ state += "…"
407
+ }
408
+ meta = append(meta, state)
409
+ }
410
+ if item.Type != "" {
411
+ itemType, truncated := githubOverviewInlinePreview(item.Type, 40)
412
+ if truncated {
413
+ itemType += "…"
414
+ }
415
+ meta = append(meta, itemType)
416
+ }
417
+ if len(meta) > 0 {
418
+ line += " — " + strings.Join(meta, " · ")
419
+ }
420
+ lines = append(lines, line)
421
+ }
422
+ if note := githubLocalOmissionNote("Project items returned by GitHub", len(project.Items)-limit); note != "" {
423
+ lines = append(lines, "", note)
424
+ }
425
+ if project.MoreItems {
426
+ lines = append(lines, "", "> More Project items exist upstream. This native long-tail Project view intentionally returns the first 50 items rather than expanding an unbounded project into context.")
427
+ }
428
+ return strings.TrimSpace(strings.Join(lines, "\n"))
429
+ }
@@ -0,0 +1,168 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "io"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "sync/atomic"
11
+ "testing"
12
+ )
13
+
14
+ func TestParsePackageProjectTargetsAndLongTailFallback(t *testing.T) {
15
+ for _, tt := range []struct {
16
+ raw string
17
+ kind GitHubTargetKind
18
+ owner string
19
+ name string
20
+ number int
21
+ tail []string
22
+ }{
23
+ {raw: "https://github.com/orgs/acme/packages/container/package/widget", kind: GitHubTargetPackage, owner: "acme", name: "widget", tail: []string{"org", "container"}},
24
+ {raw: "https://github.com/users/alice/packages/npm/package/pkg", kind: GitHubTargetPackage, owner: "alice", name: "pkg", tail: []string{"user", "npm"}},
25
+ {raw: "https://github.com/orgs/acme/projects/7", kind: GitHubTargetProjectV2, owner: "acme", number: 7, tail: []string{"org"}},
26
+ {raw: "https://github.com/users/alice/projects/3", kind: GitHubTargetProjectV2, owner: "alice", number: 3, tail: []string{"user"}},
27
+ } {
28
+ target := parseGitHubTarget(tt.raw)
29
+ if target == nil || target.Kind != tt.kind || target.Owner != tt.owner || target.Name != tt.name || target.Number != tt.number || strings.Join(target.Tail, ",") != strings.Join(tt.tail, ",") {
30
+ t.Fatalf("target %s => %#v", tt.raw, target)
31
+ }
32
+ }
33
+ for _, raw := range []string{
34
+ "https://github.com/orgs/acme/packages",
35
+ "https://github.com/users/alice/packages/container",
36
+ "https://github.com/orgs/acme/packages/unknown/package/widget",
37
+ "https://github.com/o/r/wiki",
38
+ "https://github.com/o/r/settings",
39
+ "https://github.com/o/r/security",
40
+ "https://github.com/o/r/archive/refs/heads/main.zip",
41
+ } {
42
+ if target := parseGitHubTarget(raw); target != nil {
43
+ t.Fatalf("excluded/unproven long-tail route was claimed: %s => %#v", raw, target)
44
+ }
45
+ }
46
+ }
47
+
48
+ func TestPackageDetailAndBoundedVersions(t *testing.T) {
49
+ var server *httptest.Server
50
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
51
+ w.Header().Set("Content-Type", "application/json")
52
+ switch r.URL.Path {
53
+ case "/orgs/acme/packages/container/widget":
54
+ _, _ = io.WriteString(w, `{"id":1,"name":"widget","package_type":"container","html_url":"https://github.com/orgs/acme/packages/container/package/widget","visibility":"public","description":"demo package","version_count":50,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-08-01T00:00:00Z","repository":{"full_name":"acme/widget","html_url":"https://github.com/acme/widget"}}`)
55
+ case "/orgs/acme/packages/container/widget/versions":
56
+ if r.URL.Query().Get("per_page") != "8" || r.URL.Query().Get("page") != "2" {
57
+ t.Fatalf("version pagination lost: %s", r.URL.RawQuery)
58
+ }
59
+ w.Header().Set("Link", fmt.Sprintf(`<%s/orgs/acme/packages/container/widget/versions?per_page=8&page=1>; rel="prev", <%s/orgs/acme/packages/container/widget/versions?per_page=8&page=3>; rel="next"`, server.URL, server.URL))
60
+ _, _ = io.WriteString(w, `[{"id":11,"name":"sha256:abc","package_html_url":"https://github.com/orgs/acme/packages/container/widget/11","updated_at":"2026-08-01T00:00:00Z","metadata":{"package_type":"container","container":{"tags":["latest","v1"]}}}]`)
61
+ default:
62
+ t.Fatalf("unexpected package request %s", r.URL.Path)
63
+ }
64
+ }))
65
+ defer server.Close()
66
+ out, err := readGitHubPackage(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/orgs/acme/packages/container/package/widget?page=2"))
67
+ if err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ for _, want := range []string{`package: "widget"`, `package_type: "container"`, "version_count: 50", "versions_page: 2", "demo package", "sha256:abc", "tags: latest, v1", "Previous", "page=1", "Next", "page=3"} {
71
+ if !strings.Contains(out, want) {
72
+ t.Fatalf("package output missing %q:\n%s", want, out)
73
+ }
74
+ }
75
+ }
76
+
77
+ func TestPublicOrganizationProjectV2UsesRESTWithoutTokenAndBoundsItems(t *testing.T) {
78
+ var server *httptest.Server
79
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
80
+ if r.Header.Get("Authorization") != "" {
81
+ t.Fatalf("anonymous public Project unexpectedly sent auth")
82
+ }
83
+ w.Header().Set("Content-Type", "application/json")
84
+ switch r.URL.Path {
85
+ case "/orgs/acme/projectsV2/7":
86
+ _, _ = io.WriteString(w, `{"id":71,"number":7,"title":"Roadmap","short_description":"Ship it","public":true,"state":"open","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-08-01T00:00:00Z"}`)
87
+ case "/orgs/acme/projectsV2/7/items":
88
+ if r.URL.Query().Get("per_page") != "50" {
89
+ t.Fatalf("Project items are not bounded: %s", r.URL.RawQuery)
90
+ }
91
+ w.Header().Set("Link", fmt.Sprintf(`<%s/orgs/acme/projectsV2/7/items?per_page=50&after=cursor>; rel="next"`, server.URL))
92
+ _, _ = io.WriteString(w, `[{"id":101,"content_type":"Issue","content":{"number":12,"title":"Feature","html_url":"https://github.com/acme/r/issues/12","state":"open","repository_url":"https://api.github.com/repos/acme/r"}},{"id":102,"content_type":"DraftIssue","content":{"title":"Draft task","body":"details"}}]`)
93
+ default:
94
+ t.Fatalf("unexpected Project REST path %s", r.URL.Path)
95
+ }
96
+ }))
97
+ defer server.Close()
98
+ out, err := readGitHubProjectV2(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/orgs/acme/projects/7"))
99
+ if err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ for _, want := range []string{`project: 7`, `title: "Roadmap"`, "items_returned: 2", "more_items_available: true", "Ship it", "[Feature](https://github.com/acme/r/issues/12)", "acme/r", "#12", "Draft task", "first 50 items"} {
103
+ if !strings.Contains(out, want) {
104
+ t.Fatalf("Project output missing %q:\n%s", want, out)
105
+ }
106
+ }
107
+ }
108
+
109
+ func TestPublicProjectV2RetriesWithoutNarrowFineGrainedToken(t *testing.T) {
110
+ var authCalls, anonymousCalls int32
111
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112
+ w.Header().Set("Content-Type", "application/json")
113
+ if r.Header.Get("Authorization") != "" {
114
+ atomic.AddInt32(&authCalls, 1)
115
+ w.WriteHeader(http.StatusForbidden)
116
+ _, _ = io.WriteString(w, `{"message":"Resource not accessible by personal access token"}`)
117
+ return
118
+ }
119
+ atomic.AddInt32(&anonymousCalls, 1)
120
+ switch r.URL.Path {
121
+ case "/orgs/acme/projectsV2/7":
122
+ _, _ = io.WriteString(w, `{"id":71,"number":7,"title":"Public roadmap","public":true,"state":"open"}`)
123
+ case "/orgs/acme/projectsV2/7/items":
124
+ _, _ = io.WriteString(w, `[]`)
125
+ default:
126
+ t.Fatalf("unexpected Project REST path %s", r.URL.Path)
127
+ }
128
+ }))
129
+ defer server.Close()
130
+ out, err := readGitHubProjectV2(context.Background(), testGitHubClient(server.URL, server.URL, "fake-token"), parseGitHubTarget("https://github.com/orgs/acme/projects/7"))
131
+ if err != nil || !strings.Contains(out, "# Public roadmap") {
132
+ t.Fatalf("fine-grained public Project fallback failed err=%v:\n%s", err, out)
133
+ }
134
+ if atomic.LoadInt32(&authCalls) != 2 || atomic.LoadInt32(&anonymousCalls) != 2 {
135
+ t.Fatalf("Project auth/anonymous retry counts auth=%d anon=%d", authCalls, anonymousCalls)
136
+ }
137
+ }
138
+
139
+ func TestUserProjectV2UsesUserRESTPath(t *testing.T) {
140
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
141
+ w.Header().Set("Content-Type", "application/json")
142
+ switch r.URL.Path {
143
+ case "/users/alice/projectsV2/3":
144
+ _, _ = io.WriteString(w, `{"id":31,"number":3,"title":"Personal","public":true,"state":"open"}`)
145
+ case "/users/alice/projectsV2/3/items":
146
+ _, _ = io.WriteString(w, `[]`)
147
+ default:
148
+ t.Fatalf("unexpected User Project path %s", r.URL.Path)
149
+ }
150
+ }))
151
+ defer server.Close()
152
+ out, err := readGitHubProjectV2(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/users/alice/projects/3"))
153
+ if err != nil || !strings.Contains(out, "# Personal") {
154
+ t.Fatalf("User Project failed err=%v:\n%s", err, out)
155
+ }
156
+ }
157
+
158
+ func TestPackageProjectProviderFailuresStayNative(t *testing.T) {
159
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
160
+ w.WriteHeader(http.StatusNotFound)
161
+ _, _ = io.WriteString(w, `{"message":"private body"}`)
162
+ }))
163
+ defer server.Close()
164
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/orgs/acme/packages/container/package/widget"), testGitHubClient(server.URL, server.URL, ""))
165
+ if result.Outcome != GitHubNativeFailure || result.Err == nil || strings.Contains(result.Err.Error(), "private body") {
166
+ t.Fatalf("Package native failure boundary wrong: %#v", result)
167
+ }
168
+ }