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,621 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "net/url"
9
+ "sort"
10
+ "strconv"
11
+ "strings"
12
+ )
13
+
14
+ type githubBranch struct {
15
+ Name string `json:"name"`
16
+ Protected bool `json:"protected"`
17
+ Commit struct {
18
+ SHA string `json:"sha"`
19
+ URL string `json:"url"`
20
+ } `json:"commit"`
21
+ }
22
+
23
+ type githubTag struct {
24
+ Name string `json:"name"`
25
+ ZipballURL string `json:"zipball_url"`
26
+ TarballURL string `json:"tarball_url"`
27
+ Commit struct {
28
+ SHA string `json:"sha"`
29
+ URL string `json:"url"`
30
+ } `json:"commit"`
31
+ }
32
+
33
+ type githubReleaseAsset struct {
34
+ ID int64 `json:"id"`
35
+ Name string `json:"name"`
36
+ Label string `json:"label"`
37
+ ContentType string `json:"content_type"`
38
+ State string `json:"state"`
39
+ Size int64 `json:"size"`
40
+ DownloadCount int `json:"download_count"`
41
+ BrowserDownloadURL string `json:"browser_download_url"`
42
+ CreatedAt string `json:"created_at"`
43
+ UpdatedAt string `json:"updated_at"`
44
+ Uploader githubUser `json:"uploader"`
45
+ }
46
+
47
+ type githubRelease struct {
48
+ ID int64 `json:"id"`
49
+ TagName string `json:"tag_name"`
50
+ Target string `json:"target_commitish"`
51
+ Name string `json:"name"`
52
+ Body *string `json:"body"`
53
+ HTMLURL string `json:"html_url"`
54
+ Draft bool `json:"draft"`
55
+ Prerelease bool `json:"prerelease"`
56
+ CreatedAt string `json:"created_at"`
57
+ PublishedAt string `json:"published_at"`
58
+ Author githubUser `json:"author"`
59
+ Assets []githubReleaseAsset `json:"assets"`
60
+ }
61
+
62
+ type githubReleaseAssetsAvailability struct {
63
+ ProviderMore bool
64
+ }
65
+
66
+ type githubFork struct {
67
+ FullName string `json:"full_name"`
68
+ HTMLURL string `json:"html_url"`
69
+ Description *string `json:"description"`
70
+ Language *string `json:"language"`
71
+ DefaultBranch string `json:"default_branch"`
72
+ StargazersCount int `json:"stargazers_count"`
73
+ ForksCount int `json:"forks_count"`
74
+ Archived bool `json:"archived"`
75
+ UpdatedAt string `json:"updated_at"`
76
+ Owner githubUser `json:"owner"`
77
+ }
78
+
79
+ type githubStar struct {
80
+ StarredAt string `json:"starred_at"`
81
+ User githubUser `json:"user"`
82
+ }
83
+
84
+ func readGitHubBranches(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
85
+ if target.Fragment != "" {
86
+ return "", fmt.Errorf("GitHub branches fragment %q is not a supported native selector", target.Fragment)
87
+ }
88
+ query, err := boundedListQuery(target.Query, []string{"page", "protected"})
89
+ if err != nil {
90
+ return "", err
91
+ }
92
+ endpoint := fmt.Sprintf("/repos/%s/%s/branches?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
93
+ resp, err := client.RESTPublic(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
94
+ if err != nil {
95
+ return "", err
96
+ }
97
+ var branches []githubBranch
98
+ if err := json.Unmarshal(resp.Body, &branches); err != nil {
99
+ return "", fmt.Errorf("decode GitHub branches: %w", err)
100
+ }
101
+ return renderGitHubBranches(target, branches, resp.Links()), nil
102
+ }
103
+
104
+ func readGitHubTags(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
105
+ if target.Fragment != "" {
106
+ return "", fmt.Errorf("GitHub tags fragment %q is not a supported native selector", target.Fragment)
107
+ }
108
+ query, err := boundedListQuery(target.Query, []string{"page"})
109
+ if err != nil {
110
+ return "", err
111
+ }
112
+ endpoint := fmt.Sprintf("/repos/%s/%s/tags?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
113
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
114
+ if err != nil {
115
+ return "", err
116
+ }
117
+ var tags []githubTag
118
+ if err := json.Unmarshal(resp.Body, &tags); err != nil {
119
+ return "", fmt.Errorf("decode GitHub tags: %w", err)
120
+ }
121
+ return renderGitHubTags(target, tags, resp.Links()), nil
122
+ }
123
+
124
+ func readGitHubReleases(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
125
+ if target.Fragment != "" {
126
+ return "", fmt.Errorf("GitHub releases fragment %q is not a supported native selector", target.Fragment)
127
+ }
128
+ query, err := boundedListQuery(target.Query, []string{"page"})
129
+ if err != nil {
130
+ return "", err
131
+ }
132
+ endpoint := fmt.Sprintf("/repos/%s/%s/releases?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
133
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
134
+ if err != nil {
135
+ return "", err
136
+ }
137
+ var releases []githubRelease
138
+ if err := json.Unmarshal(resp.Body, &releases); err != nil {
139
+ return "", fmt.Errorf("decode GitHub releases: %w", err)
140
+ }
141
+ return renderGitHubReleases(target, releases, resp.Links()), nil
142
+ }
143
+
144
+ func readGitHubRelease(ctx context.Context, client *GitHubClient, target *GitHubTarget, latest bool) (string, error) {
145
+ if target.Fragment != "" {
146
+ return "", fmt.Errorf("GitHub release fragment %q is not a supported native selector", target.Fragment)
147
+ }
148
+ if len(target.Query) > 0 {
149
+ return "", fmt.Errorf("GitHub release detail query parameters are not part of the native release contract")
150
+ }
151
+ endpoint := fmt.Sprintf("/repos/%s/%s/releases/latest", url.PathEscape(target.Owner), url.PathEscape(target.Repo))
152
+ if !latest {
153
+ endpoint = fmt.Sprintf("/repos/%s/%s/releases/tags/%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), url.PathEscape(target.Name))
154
+ }
155
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
156
+ if err != nil {
157
+ return "", err
158
+ }
159
+ var release githubRelease
160
+ if err := json.Unmarshal(resp.Body, &release); err != nil {
161
+ return "", fmt.Errorf("decode GitHub release: %w", err)
162
+ }
163
+ assets, availability, err := fetchGitHubReleaseAssets(ctx, client, target, release.ID)
164
+ if err != nil {
165
+ return "", err
166
+ }
167
+ sortReleaseAssetsByName(assets)
168
+ release.Assets = assets
169
+ return renderGitHubRelease(target, release, availability), nil
170
+ }
171
+
172
+ func fetchGitHubReleaseAssets(ctx context.Context, client *GitHubClient, target *GitHubTarget, releaseID int64) ([]githubReleaseAsset, githubReleaseAssetsAvailability, error) {
173
+ if releaseID <= 0 {
174
+ return nil, githubReleaseAssetsAvailability{}, fmt.Errorf("GitHub release did not report a release ID")
175
+ }
176
+ endpoint := fmt.Sprintf("/repos/%s/%s/releases/%d/assets?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), releaseID)
177
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
178
+ if err != nil {
179
+ return nil, githubReleaseAssetsAvailability{}, err
180
+ }
181
+ var assets []githubReleaseAsset
182
+ if err := json.Unmarshal(resp.Body, &assets); err != nil {
183
+ return nil, githubReleaseAssetsAvailability{}, fmt.Errorf("decode GitHub release assets: %w", err)
184
+ }
185
+ return assets, githubReleaseAssetsAvailability{ProviderMore: resp.Links()["next"] != ""}, nil
186
+ }
187
+
188
+ func readGitHubForks(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
189
+ if target.Fragment != "" {
190
+ return "", fmt.Errorf("GitHub forks fragment %q is not a supported native selector", target.Fragment)
191
+ }
192
+ query, err := boundedListQuery(target.Query, []string{"page", "sort"})
193
+ if err != nil {
194
+ return "", err
195
+ }
196
+ if sortValue := query.Get("sort"); sortValue != "" && sortValue != "newest" && sortValue != "oldest" && sortValue != "stargazers" && sortValue != "watchers" {
197
+ return "", fmt.Errorf("unsupported GitHub forks sort %q", sortValue)
198
+ }
199
+ endpoint := fmt.Sprintf("/repos/%s/%s/forks?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
200
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
201
+ if err != nil {
202
+ return "", err
203
+ }
204
+ var forks []githubFork
205
+ if err := json.Unmarshal(resp.Body, &forks); err != nil {
206
+ return "", fmt.Errorf("decode GitHub forks: %w", err)
207
+ }
208
+ return renderGitHubForks(target, forks, resp.Links()), nil
209
+ }
210
+
211
+ func readGitHubStargazers(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
212
+ if target.Fragment != "" {
213
+ return "", fmt.Errorf("GitHub stargazers fragment %q is not a supported native selector", target.Fragment)
214
+ }
215
+ query, err := boundedListQuery(target.Query, []string{"page"})
216
+ if err != nil {
217
+ return "", err
218
+ }
219
+ endpoint := fmt.Sprintf("/repos/%s/%s/stargazers?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
220
+ resp, err := client.RESTPublic(ctx, http.MethodGet, endpoint, "application/vnd.github.star+json")
221
+ if err != nil {
222
+ return "", err
223
+ }
224
+ var stars []githubStar
225
+ if err := json.Unmarshal(resp.Body, &stars); err != nil {
226
+ return "", fmt.Errorf("decode GitHub stargazers: %w", err)
227
+ }
228
+ return renderGitHubStargazers(target, stars, resp.Links()), nil
229
+ }
230
+
231
+ func readGitHubWatchers(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
232
+ if target.Fragment != "" {
233
+ return "", fmt.Errorf("GitHub watchers fragment %q is not a supported native selector", target.Fragment)
234
+ }
235
+ query, err := boundedListQuery(target.Query, []string{"page"})
236
+ if err != nil {
237
+ return "", err
238
+ }
239
+ endpoint := fmt.Sprintf("/repos/%s/%s/subscribers?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
240
+ resp, err := client.RESTPublic(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
241
+ if err != nil {
242
+ return "", err
243
+ }
244
+ var users []githubUser
245
+ if err := json.Unmarshal(resp.Body, &users); err != nil {
246
+ return "", fmt.Errorf("decode GitHub subscribers: %w", err)
247
+ }
248
+ return renderGitHubWatchers(target, users, resp.Links()), nil
249
+ }
250
+
251
+ func boundedListQuery(input url.Values, allowed []string) (url.Values, error) {
252
+ allowedSet := map[string]struct{}{}
253
+ for _, key := range allowed {
254
+ allowedSet[key] = struct{}{}
255
+ }
256
+ for key := range input {
257
+ if _, ok := allowedSet[key]; !ok {
258
+ return nil, fmt.Errorf("GitHub list query parameter %q is not supported by this native view", key)
259
+ }
260
+ }
261
+ query := copySelectedQuery(input, allowed)
262
+ query.Set("per_page", strconv.Itoa(githubPageableListSize))
263
+ if rawPage := query.Get("page"); rawPage != "" {
264
+ page, err := strconv.Atoi(rawPage)
265
+ if err != nil || page <= 0 {
266
+ return nil, fmt.Errorf("invalid GitHub list page %q", rawPage)
267
+ }
268
+ }
269
+ return query, nil
270
+ }
271
+
272
+ func renderGitHubBranches(target *GitHubTarget, branches []githubBranch, links GitHubLinkRelations) string {
273
+ limit := len(branches)
274
+ lines := listFrontmatter(target, "branches", len(branches), limit)
275
+ lines = append(lines, "# Branches", "")
276
+ if len(branches) == 0 {
277
+ lines = append(lines, "_No branches returned by GitHub._")
278
+ }
279
+ for _, branch := range branches[:limit] {
280
+ href := fmt.Sprintf("https://github.com/%s/%s/tree/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), escapePathPreservingSlashes(branch.Name))
281
+ name, truncated := githubOverviewInlinePreview(branch.Name, 120)
282
+ if truncated {
283
+ name += "…"
284
+ }
285
+ line := fmt.Sprintf("- [%s](%s)", escapeMarkdownLinkText(name), href)
286
+ meta := []string{}
287
+ if branch.Commit.SHA != "" {
288
+ meta = append(meta, "`"+shortSHA(branch.Commit.SHA)+"`")
289
+ }
290
+ if branch.Protected {
291
+ meta = append(meta, "protected")
292
+ }
293
+ if len(meta) > 0 {
294
+ line += " — " + strings.Join(meta, " · ")
295
+ }
296
+ lines = append(lines, line)
297
+ }
298
+ return appendListNavigation(lines, target, links)
299
+ }
300
+
301
+ func renderGitHubTags(target *GitHubTarget, tags []githubTag, links GitHubLinkRelations) string {
302
+ limit := len(tags)
303
+ lines := listFrontmatter(target, "tags", len(tags), limit)
304
+ lines = append(lines, "# Tags", "")
305
+ if len(tags) == 0 {
306
+ lines = append(lines, "_No tags returned by GitHub._")
307
+ }
308
+ for _, tag := range tags[:limit] {
309
+ href := fmt.Sprintf("https://github.com/%s/%s/tree/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), escapePathPreservingSlashes(tag.Name))
310
+ name, truncated := githubOverviewInlinePreview(tag.Name, 120)
311
+ if truncated {
312
+ name += "…"
313
+ }
314
+ line := fmt.Sprintf("- [%s](%s)", escapeMarkdownLinkText(name), href)
315
+ if tag.Commit.SHA != "" {
316
+ line += " — `" + shortSHA(tag.Commit.SHA) + "`"
317
+ }
318
+ lines = append(lines, line)
319
+ }
320
+ return appendListNavigation(lines, target, links)
321
+ }
322
+
323
+ func renderGitHubReleases(target *GitHubTarget, releases []githubRelease, links GitHubLinkRelations) string {
324
+ limit := len(releases)
325
+ lines := listFrontmatter(target, "releases", len(releases), limit)
326
+ lines = append(lines, "# Releases", "")
327
+ if len(releases) == 0 {
328
+ lines = append(lines, "_No releases returned by GitHub._")
329
+ }
330
+ for _, release := range releases[:limit] {
331
+ title := release.Name
332
+ if title == "" {
333
+ title = release.TagName
334
+ }
335
+ href := release.HTMLURL
336
+ if href == "" {
337
+ href = fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), escapePathPreservingSlashes(release.TagName))
338
+ }
339
+ titlePreview, truncated := githubOverviewInlinePreview(title, 140)
340
+ if truncated {
341
+ titlePreview += "…"
342
+ }
343
+ line := fmt.Sprintf("- [%s](%s)", escapeMarkdownLinkText(titlePreview), href)
344
+ meta := []string{}
345
+ if release.TagName != "" && release.TagName != title {
346
+ tagPreview, tagTruncated := githubOverviewInlinePreview(release.TagName, 100)
347
+ if tagTruncated {
348
+ tagPreview += "…"
349
+ }
350
+ meta = append(meta, "tag `"+tagPreview+"`")
351
+ }
352
+ if release.Draft {
353
+ meta = append(meta, "draft")
354
+ }
355
+ if release.Prerelease {
356
+ meta = append(meta, "prerelease")
357
+ }
358
+ if release.PublishedAt != "" {
359
+ meta = append(meta, release.PublishedAt)
360
+ }
361
+ if len(meta) > 0 {
362
+ line += " — " + strings.Join(meta, " · ")
363
+ }
364
+ lines = append(lines, line)
365
+ }
366
+ return appendListNavigation(lines, target, links)
367
+ }
368
+
369
+ func renderGitHubRelease(target *GitHubTarget, release githubRelease, availability githubReleaseAssetsAvailability) string {
370
+ assetLimit := minInt(16, len(release.Assets))
371
+ notesRunes := 2200
372
+ for {
373
+ out := renderGitHubReleaseWithLimits(target, release, availability, assetLimit, notesRunes)
374
+ if githubOverviewFits(out) {
375
+ return out
376
+ }
377
+ switch {
378
+ case assetLimit > 4:
379
+ assetLimit--
380
+ case notesRunes > 800:
381
+ notesRunes -= 200
382
+ case assetLimit > 1:
383
+ assetLimit--
384
+ default:
385
+ return out
386
+ }
387
+ }
388
+ }
389
+
390
+ func renderGitHubReleaseWithLimits(target *GitHubTarget, release githubRelease, availability githubReleaseAssetsAvailability, assetLimit, notesRunes int) string {
391
+ assetLimit = minInt(assetLimit, len(release.Assets))
392
+ canonicalURL := strings.TrimSpace(release.HTMLURL)
393
+ if canonicalURL == "" && release.TagName != "" {
394
+ canonicalURL = fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), escapePathPreservingSlashes(release.TagName))
395
+ }
396
+ tagPreview, tagTruncated := githubOverviewInlinePreview(release.TagName, 140)
397
+ if tagTruncated {
398
+ tagPreview += "…"
399
+ }
400
+ namePreview, nameTruncated := githubOverviewInlinePreview(release.Name, 180)
401
+ if nameTruncated {
402
+ namePreview += "…"
403
+ }
404
+ lines := []string{
405
+ "---",
406
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
407
+ "tag: " + yamlScalar(tagPreview),
408
+ "name: " + yamlScalar(namePreview),
409
+ fmt.Sprintf("draft: %t", release.Draft),
410
+ fmt.Sprintf("prerelease: %t", release.Prerelease),
411
+ "overview: true",
412
+ fmt.Sprintf("assets_returned: %d", len(release.Assets)),
413
+ fmt.Sprintf("assets_indexed: %d", assetLimit),
414
+ fmt.Sprintf("assets_local_omitted: %d", len(release.Assets)-assetLimit),
415
+ }
416
+ if tagTruncated {
417
+ lines = append(lines, "tag_preview_truncated: true")
418
+ }
419
+ if nameTruncated {
420
+ lines = append(lines, "name_preview_truncated: true")
421
+ }
422
+ if availability.ProviderMore {
423
+ lines = append(lines, "assets_provider_more_available: true")
424
+ }
425
+ if release.Target != "" {
426
+ lines = append(lines, "target: "+yamlScalar(release.Target))
427
+ }
428
+ if release.Author.Login != "" {
429
+ lines = append(lines, "author: "+yamlScalar("@"+release.Author.Login))
430
+ }
431
+ if release.CreatedAt != "" {
432
+ lines = append(lines, "created: "+yamlScalar(release.CreatedAt))
433
+ }
434
+ if release.PublishedAt != "" {
435
+ lines = append(lines, "published: "+yamlScalar(release.PublishedAt))
436
+ }
437
+ if canonicalURL != "" {
438
+ lines = append(lines, "url: "+yamlScalar(canonicalURL))
439
+ }
440
+ lines = append(lines, "---", "")
441
+ title := release.Name
442
+ if title == "" {
443
+ title = release.TagName
444
+ }
445
+ title, titleTruncated := githubOverviewInlinePreview(title, 180)
446
+ if titleTruncated {
447
+ title += "…"
448
+ }
449
+ lines = append(lines, "# "+title, "", "## Release notes", "")
450
+ humanNotes := ""
451
+ if release.Body != nil {
452
+ humanNotes = strings.TrimSpace(stripInvisibleHTMLComments(*release.Body))
453
+ }
454
+ if humanNotes == "" {
455
+ lines = append(lines, "_No release notes._")
456
+ } else {
457
+ preview, truncated := githubOverviewPreview(humanNotes, notesRunes)
458
+ lines = append(lines, preview)
459
+ if truncated {
460
+ lines = append(lines, "", "> Release notes preview locally truncated for this overview.")
461
+ if canonicalURL != "" {
462
+ lines = append(lines, "> Canonical GitHub release page (complete notes in the browser): "+canonicalURL)
463
+ }
464
+ }
465
+ }
466
+ lines = append(lines, "", "## Assets", "")
467
+ if len(release.Assets) == 0 {
468
+ if availability.ProviderMore {
469
+ lines = append(lines, "_No release assets were returned on the provider page fetched for this overview._")
470
+ } else {
471
+ lines = append(lines, "_No release assets._")
472
+ }
473
+ }
474
+ for _, asset := range release.Assets[:assetLimit] {
475
+ name := asset.Name
476
+ if asset.Label != "" {
477
+ name += " — " + asset.Label
478
+ }
479
+ line := "- **" + name + "**"
480
+ meta := []string{}
481
+ if asset.Size > 0 {
482
+ meta = append(meta, fmt.Sprintf("%d bytes", asset.Size))
483
+ }
484
+ if asset.ContentType != "" {
485
+ meta = append(meta, asset.ContentType)
486
+ }
487
+ if asset.DownloadCount > 0 {
488
+ meta = append(meta, fmt.Sprintf("%d downloads", asset.DownloadCount))
489
+ }
490
+ if len(meta) > 0 {
491
+ line += " — " + strings.Join(meta, " · ")
492
+ }
493
+ if asset.BrowserDownloadURL != "" {
494
+ line += " — " + asset.BrowserDownloadURL
495
+ }
496
+ lines = append(lines, line)
497
+ }
498
+ if note := githubLocalOmissionNote("release assets returned on this provider page", len(release.Assets)-assetLimit); note != "" {
499
+ lines = append(lines, "", note)
500
+ }
501
+ if availability.ProviderMore {
502
+ lines = append(lines, "", "> GitHub has more release assets beyond the provider page fetched for this overview; this provider incompleteness is separate from local overview omission.")
503
+ }
504
+ if canonicalURL != "" {
505
+ lines = append(lines, "", "Canonical release page: "+canonicalURL)
506
+ }
507
+ return strings.TrimSpace(strings.Join(lines, "\n"))
508
+ }
509
+
510
+ func renderGitHubForks(target *GitHubTarget, forks []githubFork, links GitHubLinkRelations) string {
511
+ limit := len(forks)
512
+ lines := listFrontmatter(target, "forks", len(forks), limit)
513
+ lines = append(lines, "# Forks", "")
514
+ if len(forks) == 0 {
515
+ lines = append(lines, "_No forks returned by GitHub._")
516
+ }
517
+ for _, fork := range forks[:limit] {
518
+ name := fork.FullName
519
+ if name == "" {
520
+ name = fork.Owner.Login
521
+ }
522
+ namePreview, truncated := githubOverviewInlinePreview(name, 120)
523
+ if truncated {
524
+ namePreview += "…"
525
+ }
526
+ line := "- [" + escapeMarkdownLinkText(namePreview) + "](" + fork.HTMLURL + ")"
527
+ meta := []string{}
528
+ if fork.StargazersCount > 0 {
529
+ meta = append(meta, fmt.Sprintf("%d stars", fork.StargazersCount))
530
+ }
531
+ if fork.Language != nil && strings.TrimSpace(*fork.Language) != "" {
532
+ meta = append(meta, *fork.Language)
533
+ }
534
+ if fork.Archived {
535
+ meta = append(meta, "archived")
536
+ }
537
+ if fork.UpdatedAt != "" {
538
+ meta = append(meta, "updated "+fork.UpdatedAt)
539
+ }
540
+ if len(meta) > 0 {
541
+ line += " — " + strings.Join(meta, " · ")
542
+ }
543
+ lines = append(lines, line)
544
+ }
545
+ return appendListNavigation(lines, target, links)
546
+ }
547
+
548
+ func renderGitHubStargazers(target *GitHubTarget, stars []githubStar, links GitHubLinkRelations) string {
549
+ limit := len(stars)
550
+ lines := listFrontmatter(target, "stargazers", len(stars), limit)
551
+ lines = append(lines, "# Stargazers", "", "_These are users who starred the repository. GitHub's `watchers_count` is also a historical star-count alias; it is not the subscriber count._", "")
552
+ if len(stars) == 0 {
553
+ lines = append(lines, "_No stargazers returned by GitHub._")
554
+ }
555
+ for _, star := range stars[:limit] {
556
+ line := renderGitHubUserIdentity(star.User)
557
+ if star.StarredAt != "" {
558
+ line += " — starred " + star.StarredAt
559
+ }
560
+ lines = append(lines, line)
561
+ }
562
+ return appendListNavigation(lines, target, links)
563
+ }
564
+
565
+ func renderGitHubWatchers(target *GitHubTarget, users []githubUser, links GitHubLinkRelations) string {
566
+ limit := len(users)
567
+ lines := listFrontmatter(target, "watchers", len(users), limit)
568
+ lines = append(lines, "# Watchers / subscribers", "", "_This list uses GitHub's subscribers API: these are actual repository watchers/subscribers, not stars._", "")
569
+ if len(users) == 0 {
570
+ lines = append(lines, "_No watchers/subscribers returned by GitHub._")
571
+ }
572
+ for _, user := range users[:limit] {
573
+ lines = append(lines, renderGitHubUserIdentity(user))
574
+ }
575
+ return appendListNavigation(lines, target, links)
576
+ }
577
+
578
+ func renderGitHubUserIdentity(user githubUser) string {
579
+ login := user.Login
580
+ if login == "" {
581
+ login = "unknown"
582
+ }
583
+ href := "https://github.com/" + url.PathEscape(login)
584
+ return "- [@" + escapeMarkdownLinkText(login) + "](" + href + ")"
585
+ }
586
+
587
+ func listFrontmatter(target *GitHubTarget, view string, returned int, indexed ...int) []string {
588
+ page := target.Query.Get("page")
589
+ if page == "" {
590
+ page = "1"
591
+ }
592
+ lines := []string{
593
+ "---",
594
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
595
+ "view: " + yamlScalar(view),
596
+ "page: " + page,
597
+ fmt.Sprintf("returned: %d", returned),
598
+ }
599
+ if len(indexed) > 0 {
600
+ shown := minInt(indexed[0], returned)
601
+ lines = append(lines, fmt.Sprintf("indexed: %d", shown), fmt.Sprintf("local_omitted: %d", returned-shown))
602
+ }
603
+ return append(lines, "---", "")
604
+ }
605
+
606
+ func appendListNavigation(lines []string, target *GitHubTarget, links GitHubLinkRelations) string {
607
+ if nav := renderGitHubUIPageNavigation(target, links); len(nav) > 0 {
608
+ lines = append(lines, "", "## Navigation", "")
609
+ lines = append(lines, nav...)
610
+ }
611
+ return strings.TrimSpace(strings.Join(lines, "\n"))
612
+ }
613
+
614
+ func sortReleaseAssetsByName(assets []githubReleaseAsset) {
615
+ sort.SliceStable(assets, func(i, j int) bool {
616
+ if assets[i].Name == assets[j].Name {
617
+ return assets[i].ID < assets[j].ID
618
+ }
619
+ return assets[i].Name < assets[j].Name
620
+ })
621
+ }