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,1156 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "net/url"
10
+ "regexp"
11
+ "sort"
12
+ "strconv"
13
+ "strings"
14
+ "unicode"
15
+ "unicode/utf8"
16
+ )
17
+
18
+ type githubDiscussionSummary struct {
19
+ Number int
20
+ Title string
21
+ URL string
22
+ CreatedAt string
23
+ UpdatedAt string
24
+ UpvoteCount int
25
+ Locked bool
26
+ Author string
27
+ Category string
28
+ }
29
+
30
+ type githubDiscussionComment struct {
31
+ ID string
32
+ DatabaseID int64
33
+ ParentDatabaseID int64
34
+ Body string
35
+ URL string
36
+ CreatedAt string
37
+ UpdatedAt string
38
+ UpvoteCount int
39
+ Author string
40
+ Replies []githubDiscussionComment
41
+ RepliesReported int
42
+ RepliesProviderMore bool
43
+ }
44
+
45
+ type githubDiscussionDetail struct {
46
+ githubDiscussionSummary
47
+ Body string
48
+ Answer *githubDiscussionComment
49
+ Comments []githubDiscussionComment
50
+ CommentsReported int
51
+ CommentsProviderMore bool
52
+ }
53
+
54
+ type githubGistFile struct {
55
+ Filename string `json:"filename"`
56
+ Type string `json:"type"`
57
+ Language string `json:"language"`
58
+ RawURL string `json:"raw_url"`
59
+ Size int64 `json:"size"`
60
+ Truncated bool `json:"truncated"`
61
+ Content string `json:"content"`
62
+ }
63
+
64
+ type githubGistHistory struct {
65
+ Version string `json:"version"`
66
+ CommittedAt string `json:"committed_at"`
67
+ URL string `json:"url"`
68
+ ChangeStatus struct {
69
+ Total int `json:"total"`
70
+ Additions int `json:"additions"`
71
+ Deletions int `json:"deletions"`
72
+ } `json:"change_status"`
73
+ }
74
+
75
+ type githubGist struct {
76
+ ID string `json:"id"`
77
+ Description *string `json:"description"`
78
+ Public bool `json:"public"`
79
+ HTMLURL string `json:"html_url"`
80
+ CreatedAt string `json:"created_at"`
81
+ UpdatedAt string `json:"updated_at"`
82
+ Comments int `json:"comments"`
83
+ Owner githubUser `json:"owner"`
84
+ Files map[string]githubGistFile `json:"files"`
85
+ History []githubGistHistory `json:"history"`
86
+ }
87
+
88
+ type githubGistComment struct {
89
+ ID int64 `json:"id"`
90
+ Body *string `json:"body"`
91
+ User githubUser `json:"user"`
92
+ CreatedAt string `json:"created_at"`
93
+ UpdatedAt string `json:"updated_at"`
94
+ APIURL string `json:"url"`
95
+ HTMLURL string `json:"html_url"`
96
+ }
97
+
98
+ type githubGistCommentsAvailability struct {
99
+ ProviderMore bool
100
+ }
101
+
102
+ type githubDiscussionPageInfo struct {
103
+ HasNextPage bool `json:"hasNextPage"`
104
+ EndCursor string `json:"endCursor"`
105
+ }
106
+
107
+ type githubDiscussionGraphQLComment struct {
108
+ ID string `json:"id"`
109
+ DatabaseID int64 `json:"databaseId"`
110
+ Body string `json:"body"`
111
+ URL string `json:"url"`
112
+ CreatedAt string `json:"createdAt"`
113
+ UpdatedAt string `json:"updatedAt"`
114
+ UpvoteCount int `json:"upvoteCount"`
115
+ Author *struct {
116
+ Login string `json:"login"`
117
+ } `json:"author"`
118
+ Replies struct {
119
+ Nodes []githubDiscussionGraphQLComment `json:"nodes"`
120
+ TotalCount int `json:"totalCount"`
121
+ PageInfo githubDiscussionPageInfo `json:"pageInfo"`
122
+ } `json:"replies"`
123
+ }
124
+
125
+ type githubDiscussionGraphQLDetail struct {
126
+ Number int `json:"number"`
127
+ Title string `json:"title"`
128
+ Body string `json:"body"`
129
+ URL string `json:"url"`
130
+ CreatedAt string `json:"createdAt"`
131
+ UpdatedAt string `json:"updatedAt"`
132
+ UpvoteCount int `json:"upvoteCount"`
133
+ Locked bool `json:"locked"`
134
+ Author *struct {
135
+ Login string `json:"login"`
136
+ } `json:"author"`
137
+ Category struct {
138
+ Name string `json:"name"`
139
+ } `json:"category"`
140
+ Answer *githubDiscussionGraphQLComment `json:"answer"`
141
+ Comments struct {
142
+ Nodes []githubDiscussionGraphQLComment `json:"nodes"`
143
+ TotalCount int `json:"totalCount"`
144
+ PageInfo githubDiscussionPageInfo `json:"pageInfo"`
145
+ } `json:"comments"`
146
+ }
147
+
148
+ type gistFileSelector struct {
149
+ Slug string
150
+ Start int
151
+ End int
152
+ }
153
+
154
+ var (
155
+ discussionCommentFragmentRE = regexp.MustCompile(`^discussioncomment-([0-9]+)$`)
156
+ gistCommentFragmentRE = regexp.MustCompile(`^gistcomment-([0-9]+)$`)
157
+ gistLineSuffixRE = regexp.MustCompile(`^(file-.+?)(?:-L([0-9]+)(?:-L([0-9]+))?)?$`)
158
+ )
159
+
160
+ func readGitHubDiscussions(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
161
+ if !client.hasToken() {
162
+ return "", fmt.Errorf("GitHub Discussions require authentication. Set GH_TOKEN or GITHUB_TOKEN")
163
+ }
164
+ if target.Fragment != "" || len(target.Query) > 0 {
165
+ return "", fmt.Errorf("GitHub Discussions list filters/selectors are not yet part of the native bounded list contract")
166
+ }
167
+ const query = `query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){discussions(first:30,orderBy:{field:UPDATED_AT,direction:DESC}){nodes{number title url createdAt updatedAt upvoteCount locked author{login} category{name}} pageInfo{hasNextPage endCursor}}}}`
168
+ var data struct {
169
+ Repository *struct {
170
+ Discussions struct {
171
+ Nodes []struct {
172
+ Number int `json:"number"`
173
+ Title string `json:"title"`
174
+ URL string `json:"url"`
175
+ CreatedAt string `json:"createdAt"`
176
+ UpdatedAt string `json:"updatedAt"`
177
+ UpvoteCount int `json:"upvoteCount"`
178
+ Locked bool `json:"locked"`
179
+ Author *struct {
180
+ Login string `json:"login"`
181
+ } `json:"author"`
182
+ Category struct {
183
+ Name string `json:"name"`
184
+ } `json:"category"`
185
+ } `json:"nodes"`
186
+ PageInfo struct {
187
+ HasNextPage bool `json:"hasNextPage"`
188
+ EndCursor string `json:"endCursor"`
189
+ } `json:"pageInfo"`
190
+ } `json:"discussions"`
191
+ } `json:"repository"`
192
+ }
193
+ if err := client.GraphQL(ctx, query, map[string]any{"owner": target.Owner, "repo": target.Repo}, &data); err != nil {
194
+ return "", err
195
+ }
196
+ if data.Repository == nil {
197
+ return "", fmt.Errorf("GitHub Discussions repository was not available")
198
+ }
199
+ items := make([]githubDiscussionSummary, 0, len(data.Repository.Discussions.Nodes))
200
+ for _, node := range data.Repository.Discussions.Nodes {
201
+ item := githubDiscussionSummary{Number: node.Number, Title: node.Title, URL: node.URL, CreatedAt: node.CreatedAt, UpdatedAt: node.UpdatedAt, UpvoteCount: node.UpvoteCount, Locked: node.Locked, Category: node.Category.Name}
202
+ if node.Author != nil {
203
+ item.Author = node.Author.Login
204
+ }
205
+ items = append(items, item)
206
+ }
207
+ return renderGitHubDiscussions(target, items, data.Repository.Discussions.PageInfo.HasNextPage), nil
208
+ }
209
+
210
+ func readGitHubDiscussion(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
211
+ if !client.hasToken() {
212
+ return "", fmt.Errorf("GitHub Discussions require authentication. Set GH_TOKEN or GITHUB_TOKEN")
213
+ }
214
+ if len(target.Query) > 0 {
215
+ return "", fmt.Errorf("GitHub Discussion query parameters are not part of the native Discussion contract")
216
+ }
217
+ databaseID, hasSelector, err := parseDiscussionCommentSelector(target.Fragment)
218
+ if err != nil {
219
+ return "", err
220
+ }
221
+ if hasSelector {
222
+ detail, comment, err := fetchGitHubDiscussionCommentByDatabaseID(ctx, client, target, databaseID)
223
+ if err != nil {
224
+ return "", err
225
+ }
226
+ return renderGitHubSelectedDiscussionComment(target, detail, comment), nil
227
+ }
228
+ detail, err := fetchGitHubDiscussionOverview(ctx, client, target)
229
+ if err != nil {
230
+ return "", err
231
+ }
232
+ return renderGitHubDiscussion(target, detail), nil
233
+ }
234
+
235
+ func parseDiscussionCommentSelector(fragment string) (int64, bool, error) {
236
+ if fragment == "" {
237
+ return 0, false, nil
238
+ }
239
+ match := discussionCommentFragmentRE.FindStringSubmatch(fragment)
240
+ if match == nil {
241
+ return 0, true, fmt.Errorf("GitHub Discussion fragment %q is not a supported discussion-comment selector", fragment)
242
+ }
243
+ id, err := strconv.ParseInt(match[1], 10, 64)
244
+ if err != nil || id <= 0 {
245
+ return 0, true, fmt.Errorf("invalid GitHub Discussion comment selector %q", fragment)
246
+ }
247
+ return id, true, nil
248
+ }
249
+
250
+ func mapDiscussionGraphQLComment(node githubDiscussionGraphQLComment, parentDatabaseID int64) githubDiscussionComment {
251
+ comment := githubDiscussionComment{
252
+ ID: node.ID,
253
+ DatabaseID: node.DatabaseID,
254
+ ParentDatabaseID: parentDatabaseID,
255
+ Body: node.Body,
256
+ URL: node.URL,
257
+ CreatedAt: node.CreatedAt,
258
+ UpdatedAt: node.UpdatedAt,
259
+ UpvoteCount: node.UpvoteCount,
260
+ RepliesReported: node.Replies.TotalCount,
261
+ RepliesProviderMore: node.Replies.PageInfo.HasNextPage || node.Replies.TotalCount > len(node.Replies.Nodes),
262
+ }
263
+ if node.Author != nil {
264
+ comment.Author = node.Author.Login
265
+ }
266
+ for _, replyNode := range node.Replies.Nodes {
267
+ comment.Replies = append(comment.Replies, mapDiscussionGraphQLComment(replyNode, node.DatabaseID))
268
+ }
269
+ return comment
270
+ }
271
+
272
+ func mapDiscussionGraphQLDetail(d *githubDiscussionGraphQLDetail) githubDiscussionDetail {
273
+ detail := githubDiscussionDetail{
274
+ githubDiscussionSummary: githubDiscussionSummary{
275
+ Number: d.Number, Title: d.Title, URL: d.URL, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt,
276
+ UpvoteCount: d.UpvoteCount, Locked: d.Locked, Category: d.Category.Name,
277
+ },
278
+ Body: d.Body,
279
+ CommentsReported: d.Comments.TotalCount,
280
+ CommentsProviderMore: d.Comments.PageInfo.HasNextPage || d.Comments.TotalCount > len(d.Comments.Nodes),
281
+ }
282
+ if d.Author != nil {
283
+ detail.Author = d.Author.Login
284
+ }
285
+ if d.Answer != nil {
286
+ answer := mapDiscussionGraphQLComment(*d.Answer, 0)
287
+ detail.Answer = &answer
288
+ }
289
+ for _, node := range d.Comments.Nodes {
290
+ detail.Comments = append(detail.Comments, mapDiscussionGraphQLComment(node, 0))
291
+ }
292
+ return detail
293
+ }
294
+
295
+ func fetchGitHubDiscussionOverview(ctx context.Context, client *GitHubClient, target *GitHubTarget) (githubDiscussionDetail, error) {
296
+ const query = `query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){discussion(number:$number){number title body url createdAt updatedAt upvoteCount locked author{login} category{name} answer{id databaseId body url createdAt updatedAt upvoteCount author{login}} comments(first:30){totalCount nodes{id databaseId body url createdAt updatedAt upvoteCount author{login} replies(first:5){totalCount nodes{id databaseId body url createdAt updatedAt upvoteCount author{login}} pageInfo{hasNextPage endCursor}}} pageInfo{hasNextPage endCursor}}}}}`
297
+ var data struct {
298
+ Repository *struct {
299
+ Discussion *githubDiscussionGraphQLDetail `json:"discussion"`
300
+ } `json:"repository"`
301
+ }
302
+ if err := client.GraphQL(ctx, query, map[string]any{"owner": target.Owner, "repo": target.Repo, "number": target.Number}, &data); err != nil {
303
+ return githubDiscussionDetail{}, err
304
+ }
305
+ if data.Repository == nil || data.Repository.Discussion == nil {
306
+ return githubDiscussionDetail{}, fmt.Errorf("GitHub Discussion #%d was not available", target.Number)
307
+ }
308
+ return mapDiscussionGraphQLDetail(data.Repository.Discussion), nil
309
+ }
310
+
311
+ type githubDiscussionReplyCursor struct {
312
+ ParentID string
313
+ ParentDatabaseID int64
314
+ Cursor string
315
+ }
316
+
317
+ func fetchGitHubDiscussionCommentByDatabaseID(ctx context.Context, client *GitHubClient, target *GitHubTarget, databaseID int64) (githubDiscussionDetail, githubDiscussionComment, error) {
318
+ const query = `query($owner:String!,$repo:String!,$number:Int!,$after:String){repository(owner:$owner,name:$repo){discussion(number:$number){number title url createdAt updatedAt upvoteCount locked author{login} category{name} comments(first:100,after:$after){totalCount nodes{id databaseId body url createdAt updatedAt upvoteCount author{login} replies(first:100){totalCount nodes{id databaseId body url createdAt updatedAt upvoteCount author{login}} pageInfo{hasNextPage endCursor}}} pageInfo{hasNextPage endCursor}}}}}`
319
+ var detail githubDiscussionDetail
320
+ var after any
321
+ var deferred []githubDiscussionReplyCursor
322
+ for {
323
+ var data struct {
324
+ Repository *struct {
325
+ Discussion *githubDiscussionGraphQLDetail `json:"discussion"`
326
+ } `json:"repository"`
327
+ }
328
+ if err := client.GraphQL(ctx, query, map[string]any{"owner": target.Owner, "repo": target.Repo, "number": target.Number, "after": after}, &data); err != nil {
329
+ return githubDiscussionDetail{}, githubDiscussionComment{}, err
330
+ }
331
+ if data.Repository == nil || data.Repository.Discussion == nil {
332
+ return githubDiscussionDetail{}, githubDiscussionComment{}, fmt.Errorf("GitHub Discussion #%d was not available", target.Number)
333
+ }
334
+ d := data.Repository.Discussion
335
+ if detail.Number == 0 {
336
+ detail = mapDiscussionGraphQLDetail(d)
337
+ detail.Comments = nil
338
+ }
339
+ for _, node := range d.Comments.Nodes {
340
+ comment := mapDiscussionGraphQLComment(node, 0)
341
+ if comment.DatabaseID == databaseID {
342
+ if err := validateDiscussionCommentURL(target, comment, databaseID); err != nil {
343
+ return githubDiscussionDetail{}, githubDiscussionComment{}, err
344
+ }
345
+ return detail, comment, nil
346
+ }
347
+ for _, reply := range comment.Replies {
348
+ if reply.DatabaseID == databaseID {
349
+ if err := validateDiscussionCommentURL(target, reply, databaseID); err != nil {
350
+ return githubDiscussionDetail{}, githubDiscussionComment{}, err
351
+ }
352
+ return detail, reply, nil
353
+ }
354
+ }
355
+ if node.Replies.PageInfo.HasNextPage {
356
+ deferred = append(deferred, githubDiscussionReplyCursor{ParentID: node.ID, ParentDatabaseID: node.DatabaseID, Cursor: node.Replies.PageInfo.EndCursor})
357
+ }
358
+ }
359
+ if !d.Comments.PageInfo.HasNextPage {
360
+ break
361
+ }
362
+ after = d.Comments.PageInfo.EndCursor
363
+ }
364
+ for _, pending := range deferred {
365
+ reply, found, err := fetchGitHubDiscussionReplyByDatabaseID(ctx, client, pending, databaseID)
366
+ if err != nil {
367
+ return githubDiscussionDetail{}, githubDiscussionComment{}, err
368
+ }
369
+ if found {
370
+ if err := validateDiscussionCommentURL(target, reply, databaseID); err != nil {
371
+ return githubDiscussionDetail{}, githubDiscussionComment{}, err
372
+ }
373
+ return detail, reply, nil
374
+ }
375
+ }
376
+ return githubDiscussionDetail{}, githubDiscussionComment{}, fmt.Errorf("GitHub Discussion comment selector %q was not found in Discussion #%d", target.Fragment, target.Number)
377
+ }
378
+
379
+ func fetchGitHubDiscussionReplyByDatabaseID(ctx context.Context, client *GitHubClient, pending githubDiscussionReplyCursor, databaseID int64) (githubDiscussionComment, bool, error) {
380
+ const query = `query($id:ID!,$after:String){node(id:$id){... on DiscussionComment{replies(first:100,after:$after){nodes{id databaseId body url createdAt updatedAt upvoteCount author{login}} pageInfo{hasNextPage endCursor}}}}}`
381
+ var after any = pending.Cursor
382
+ for {
383
+ var data struct {
384
+ Node *struct {
385
+ Replies struct {
386
+ Nodes []githubDiscussionGraphQLComment `json:"nodes"`
387
+ PageInfo githubDiscussionPageInfo `json:"pageInfo"`
388
+ } `json:"replies"`
389
+ } `json:"node"`
390
+ }
391
+ if err := client.GraphQL(ctx, query, map[string]any{"id": pending.ParentID, "after": after}, &data); err != nil {
392
+ return githubDiscussionComment{}, false, err
393
+ }
394
+ if data.Node == nil {
395
+ return githubDiscussionComment{}, false, fmt.Errorf("GitHub Discussion comment reply connection was unavailable")
396
+ }
397
+ for _, node := range data.Node.Replies.Nodes {
398
+ reply := mapDiscussionGraphQLComment(node, pending.ParentDatabaseID)
399
+ if reply.DatabaseID == databaseID {
400
+ return reply, true, nil
401
+ }
402
+ }
403
+ if !data.Node.Replies.PageInfo.HasNextPage {
404
+ return githubDiscussionComment{}, false, nil
405
+ }
406
+ after = data.Node.Replies.PageInfo.EndCursor
407
+ }
408
+ }
409
+
410
+ func validateDiscussionCommentURL(target *GitHubTarget, comment githubDiscussionComment, databaseID int64) error {
411
+ if comment.DatabaseID != databaseID || strings.TrimSpace(comment.URL) == "" {
412
+ return fmt.Errorf("GitHub Discussion comment %d did not return stable selector identity", databaseID)
413
+ }
414
+ parsed, err := url.Parse(comment.URL)
415
+ if err != nil || !strings.EqualFold(parsed.Hostname(), "github.com") || parsed.Fragment != fmt.Sprintf("discussioncomment-%d", databaseID) {
416
+ return fmt.Errorf("GitHub Discussion comment %d returned unexpected canonical URL %q", databaseID, comment.URL)
417
+ }
418
+ parts := splitGitHubPath(parsed.Path)
419
+ if len(parts) < 4 || parts[len(parts)-2] != "discussions" {
420
+ return fmt.Errorf("GitHub Discussion comment %d returned unexpected canonical URL %q", databaseID, comment.URL)
421
+ }
422
+ number, err := strconv.Atoi(parts[len(parts)-1])
423
+ if err != nil || number != target.Number {
424
+ return fmt.Errorf("GitHub Discussion comment %d belongs to a different Discussion", databaseID)
425
+ }
426
+ return nil
427
+ }
428
+
429
+ func renderGitHubDiscussions(target *GitHubTarget, items []githubDiscussionSummary, hasMore bool) string {
430
+ lines := []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), "view: discussions", fmt.Sprintf("returned: %d", len(items)), fmt.Sprintf("more_available: %t", hasMore), "---", "", "# Discussions", ""}
431
+ if len(items) == 0 {
432
+ lines = append(lines, "_No Discussions returned by GitHub._")
433
+ }
434
+ for _, item := range items {
435
+ line := fmt.Sprintf("- [#%d %s](%s)", item.Number, escapeMarkdownLinkText(item.Title), item.URL)
436
+ meta := []string{}
437
+ if item.Category != "" {
438
+ meta = append(meta, item.Category)
439
+ }
440
+ if item.Author != "" {
441
+ meta = append(meta, "@"+item.Author)
442
+ }
443
+ if item.UpvoteCount > 0 {
444
+ meta = append(meta, fmt.Sprintf("%d upvotes", item.UpvoteCount))
445
+ }
446
+ if item.Locked {
447
+ meta = append(meta, "locked")
448
+ }
449
+ if item.UpdatedAt != "" {
450
+ meta = append(meta, "updated "+item.UpdatedAt)
451
+ }
452
+ if len(meta) > 0 {
453
+ line += " — " + strings.Join(meta, " · ")
454
+ }
455
+ lines = append(lines, line)
456
+ }
457
+ if hasMore {
458
+ lines = append(lines, "", "> More Discussions exist upstream. This native list intentionally returns only the first 30 because GitHub's GraphQL cursor is not a stable copied-page URL selector.")
459
+ }
460
+ return strings.TrimSpace(strings.Join(lines, "\n"))
461
+ }
462
+
463
+ func renderGitHubDiscussion(target *GitHubTarget, detail githubDiscussionDetail) string {
464
+ comments, answerReturned := prioritizeDiscussionAnswer(detail.Comments, detail.Answer)
465
+ commentLimit := minInt(10, len(comments))
466
+ replyLimit := 2
467
+ bodyRunes := 1200
468
+ commentRunes := 220
469
+ answerRunes := 500
470
+ for {
471
+ out := renderGitHubDiscussionWithLimits(target, detail, comments, answerReturned, commentLimit, replyLimit, bodyRunes, commentRunes, answerRunes)
472
+ if githubOverviewFits(out) {
473
+ return out
474
+ }
475
+ switch {
476
+ case commentLimit > 4:
477
+ commentLimit--
478
+ case bodyRunes > 700:
479
+ bodyRunes -= 100
480
+ case replyLimit > 1:
481
+ replyLimit--
482
+ case commentRunes > 140:
483
+ commentRunes -= 20
484
+ case answerRunes > 300:
485
+ answerRunes -= 50
486
+ case commentLimit > 1:
487
+ commentLimit--
488
+ default:
489
+ return out
490
+ }
491
+ }
492
+ }
493
+
494
+ func prioritizeDiscussionAnswer(comments []githubDiscussionComment, answer *githubDiscussionComment) ([]githubDiscussionComment, bool) {
495
+ out := append([]githubDiscussionComment(nil), comments...)
496
+ if answer == nil {
497
+ return out, false
498
+ }
499
+ for i := range out {
500
+ if discussionCommentSameIdentity(out[i], *answer) {
501
+ if i > 0 {
502
+ out[0], out[i] = out[i], out[0]
503
+ }
504
+ return out, true
505
+ }
506
+ }
507
+ return out, false
508
+ }
509
+
510
+ func discussionCommentSameIdentity(a, b githubDiscussionComment) bool {
511
+ if a.ID != "" && b.ID != "" {
512
+ return a.ID == b.ID
513
+ }
514
+ return a.DatabaseID > 0 && a.DatabaseID == b.DatabaseID
515
+ }
516
+
517
+ func renderGitHubDiscussionWithLimits(target *GitHubTarget, detail githubDiscussionDetail, comments []githubDiscussionComment, answerReturned bool, commentLimit, replyLimit, bodyRunes, commentRunes, answerRunes int) string {
518
+ commentLimit = minInt(commentLimit, len(comments))
519
+ titlePreview, titleTruncated := githubOverviewInlinePreview(detail.Title, 180)
520
+ if titleTruncated {
521
+ titlePreview += "…"
522
+ }
523
+ categoryPreview, categoryTruncated := githubOverviewInlinePreview(detail.Category, 120)
524
+ if categoryTruncated {
525
+ categoryPreview += "…"
526
+ }
527
+ lines := []string{
528
+ "---",
529
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
530
+ fmt.Sprintf("discussion: %d", detail.Number),
531
+ "title: " + yamlScalar(titlePreview),
532
+ "category: " + yamlScalar(categoryPreview),
533
+ fmt.Sprintf("locked: %t", detail.Locked),
534
+ fmt.Sprintf("upvotes: %d", detail.UpvoteCount),
535
+ "overview: true",
536
+ fmt.Sprintf("comments_reported: %d", detail.CommentsReported),
537
+ fmt.Sprintf("comments_returned: %d", len(detail.Comments)),
538
+ fmt.Sprintf("comments_indexed: %d", commentLimit),
539
+ fmt.Sprintf("comments_local_omitted: %d", len(detail.Comments)-commentLimit),
540
+ }
541
+ if titleTruncated {
542
+ lines = append(lines, "title_preview_truncated: true")
543
+ }
544
+ if categoryTruncated {
545
+ lines = append(lines, "category_preview_truncated: true")
546
+ }
547
+ if detail.CommentsProviderMore {
548
+ lines = append(lines, "comments_provider_more_available: true")
549
+ }
550
+ if detail.Answer != nil {
551
+ if detail.Answer.DatabaseID > 0 {
552
+ lines = append(lines, fmt.Sprintf("accepted_answer_comment_id: %d", detail.Answer.DatabaseID))
553
+ }
554
+ if detail.Answer.URL != "" {
555
+ lines = append(lines, "accepted_answer_url: "+yamlScalar(detail.Answer.URL))
556
+ }
557
+ if !answerReturned {
558
+ lines = append(lines, "accepted_answer_outside_returned_comment_page: true")
559
+ }
560
+ }
561
+ if detail.Author != "" {
562
+ lines = append(lines, "author: "+yamlScalar("@"+detail.Author))
563
+ }
564
+ if detail.CreatedAt != "" {
565
+ lines = append(lines, "created: "+yamlScalar(detail.CreatedAt))
566
+ }
567
+ if detail.UpdatedAt != "" {
568
+ lines = append(lines, "updated: "+yamlScalar(detail.UpdatedAt))
569
+ }
570
+ if detail.URL != "" {
571
+ lines = append(lines, "url: "+yamlScalar(detail.URL))
572
+ }
573
+ lines = append(lines, "---", "", "# "+titlePreview, "")
574
+ body := strings.TrimSpace(stripInvisibleHTMLComments(detail.Body))
575
+ if body == "" {
576
+ body = "_No Discussion body._"
577
+ }
578
+ bodyPreview, bodyTruncated := githubOverviewPreview(body, bodyRunes)
579
+ lines = append(lines, bodyPreview)
580
+ if bodyTruncated {
581
+ lines = append(lines, "", "> Discussion body preview locally truncated. Full Discussion: "+detail.URL)
582
+ }
583
+ if detail.Answer != nil && !answerReturned {
584
+ lines = append(lines, "", "## Accepted answer", "")
585
+ lines = append(lines, renderDiscussionCommentIndex(*detail.Answer, true, 3, answerRunes)...)
586
+ }
587
+ lines = append(lines, "", "## Conversation index", "")
588
+ if len(detail.Comments) == 0 {
589
+ lines = append(lines, "_No comments returned by GitHub._")
590
+ }
591
+ for _, comment := range comments[:commentLimit] {
592
+ answer := detail.Answer != nil && discussionCommentSameIdentity(comment, *detail.Answer)
593
+ lines = append(lines, renderDiscussionCommentIndex(comment, answer, 3, commentRunes)...)
594
+ shownReplies := minInt(replyLimit, len(comment.Replies))
595
+ for _, reply := range comment.Replies[:shownReplies] {
596
+ lines = append(lines, renderDiscussionCommentIndex(reply, false, 4, commentRunes)...)
597
+ }
598
+ if omitted := len(comment.Replies) - shownReplies; omitted > 0 {
599
+ lines = append(lines, fmt.Sprintf("> %d replies returned for comment `%d` locally omitted from this overview.", omitted, comment.DatabaseID), "")
600
+ }
601
+ if comment.RepliesProviderMore {
602
+ lines = append(lines, fmt.Sprintf("> More replies to comment `%d` exist upstream beyond the provider page fetched for this overview.", comment.DatabaseID), "")
603
+ }
604
+ }
605
+ if note := githubLocalOmissionNote("top-level Discussion comments returned by GitHub", len(detail.Comments)-commentLimit); note != "" {
606
+ lines = append(lines, note)
607
+ }
608
+ if detail.CommentsProviderMore {
609
+ lines = append(lines, "", "> More top-level Discussion comments exist upstream beyond the provider page fetched for this overview. Copy an indexed `#discussioncomment-...` URL for an exact comment read.")
610
+ }
611
+ return strings.TrimSpace(strings.Join(lines, "\n"))
612
+ }
613
+
614
+ func renderDiscussionCommentIndex(comment githubDiscussionComment, answer bool, level, maxRunes int) []string {
615
+ heading := strings.Repeat("#", level) + " "
616
+ if answer {
617
+ heading += "Accepted answer"
618
+ } else if comment.ParentDatabaseID > 0 || level > 3 {
619
+ heading += "Reply"
620
+ } else {
621
+ heading += "Comment"
622
+ }
623
+ if comment.DatabaseID > 0 {
624
+ heading += fmt.Sprintf(" `%d`", comment.DatabaseID)
625
+ }
626
+ if comment.Author != "" {
627
+ heading += " by @" + comment.Author
628
+ }
629
+ if comment.CreatedAt != "" {
630
+ heading += " — " + comment.CreatedAt
631
+ }
632
+ lines := []string{heading, ""}
633
+ meta := []string{}
634
+ if comment.UpvoteCount > 0 {
635
+ meta = append(meta, fmt.Sprintf("%d upvotes", comment.UpvoteCount))
636
+ }
637
+ if comment.ParentDatabaseID > 0 {
638
+ meta = append(meta, fmt.Sprintf("reply to `%d`", comment.ParentDatabaseID))
639
+ }
640
+ if comment.URL != "" {
641
+ meta = append(meta, comment.URL)
642
+ }
643
+ if len(meta) > 0 {
644
+ lines = append(lines, "_"+strings.Join(meta, " · ")+"_", "")
645
+ }
646
+ body := strings.TrimSpace(stripInvisibleHTMLComments(comment.Body))
647
+ if body == "" {
648
+ body = "_Empty comment._"
649
+ }
650
+ preview, truncated := githubOverviewPreview(body, maxRunes)
651
+ lines = append(lines, preview)
652
+ if truncated {
653
+ lines = append(lines, "", "> Comment preview locally truncated; use the canonical comment URL above for exact/full context.")
654
+ }
655
+ lines = append(lines, "")
656
+ return lines
657
+ }
658
+
659
+ func renderGitHubSelectedDiscussionComment(target *GitHubTarget, detail githubDiscussionDetail, comment githubDiscussionComment) string {
660
+ lines := []string{
661
+ "---",
662
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
663
+ fmt.Sprintf("discussion: %d", detail.Number),
664
+ "selector: " + yamlScalar(target.Fragment),
665
+ fmt.Sprintf("comment_id: %d", comment.DatabaseID),
666
+ }
667
+ if comment.ParentDatabaseID > 0 {
668
+ lines = append(lines, fmt.Sprintf("parent_comment_id: %d", comment.ParentDatabaseID))
669
+ }
670
+ if comment.Author != "" {
671
+ lines = append(lines, "author: "+yamlScalar("@"+comment.Author))
672
+ }
673
+ if comment.CreatedAt != "" {
674
+ lines = append(lines, "created: "+yamlScalar(comment.CreatedAt))
675
+ }
676
+ if comment.UpdatedAt != "" {
677
+ lines = append(lines, "updated: "+yamlScalar(comment.UpdatedAt))
678
+ }
679
+ if comment.URL != "" {
680
+ lines = append(lines, "url: "+yamlScalar(comment.URL))
681
+ }
682
+ lines = append(lines, "---", "", fmt.Sprintf("# Discussion comment %d", comment.DatabaseID), "")
683
+ if detail.Title != "" {
684
+ lines = append(lines, "From: [#"+strconv.Itoa(detail.Number)+" "+escapeMarkdownLinkText(detail.Title)+"]("+detail.URL+")", "")
685
+ }
686
+ body := strings.TrimSpace(stripInvisibleHTMLComments(comment.Body))
687
+ if body == "" {
688
+ body = "_Empty comment._"
689
+ }
690
+ lines = append(lines, body)
691
+ return strings.TrimSpace(strings.Join(lines, "\n"))
692
+ }
693
+
694
+ func readGitHubGist(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
695
+ if len(target.Query) > 0 {
696
+ return "", fmt.Errorf("GitHub Gist query parameters are not part of the native Gist contract")
697
+ }
698
+ endpoint := "/gists/" + url.PathEscape(target.Name)
699
+ if len(target.Tail) == 1 {
700
+ endpoint += "/" + url.PathEscape(target.Tail[0])
701
+ }
702
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
703
+ if err != nil {
704
+ return "", err
705
+ }
706
+ var gist githubGist
707
+ if err := json.Unmarshal(resp.Body, &gist); err != nil {
708
+ return "", fmt.Errorf("decode GitHub Gist: %w", err)
709
+ }
710
+ if gist.ID != "" && gist.ID != target.Name {
711
+ return "", fmt.Errorf("GitHub Gist response identity %q did not match requested Gist %q", gist.ID, target.Name)
712
+ }
713
+ commentID, hasCommentSelector, err := parseGistCommentSelector(target.Fragment)
714
+ if err != nil {
715
+ return "", err
716
+ }
717
+ if hasCommentSelector {
718
+ if len(target.Tail) > 0 {
719
+ return "", fmt.Errorf("GitHub Gist comment selectors apply to the canonical Gist, not a revision URL")
720
+ }
721
+ comment, err := fetchGitHubGistComment(ctx, client, target.Name, commentID)
722
+ if err != nil {
723
+ return "", err
724
+ }
725
+ return renderGitHubSelectedGistComment(target, gist, comment), nil
726
+ }
727
+ selector, hasFileSelector, err := parseGistFileSelector(target.Fragment)
728
+ if err != nil {
729
+ return "", err
730
+ }
731
+ files := sortedGitHubGistFiles(gist.Files)
732
+ if hasFileSelector {
733
+ matched := -1
734
+ for i := range files {
735
+ if gistFileSlug(files[i].Filename) == selector.Slug {
736
+ matched = i
737
+ break
738
+ }
739
+ }
740
+ if matched < 0 {
741
+ return "", fmt.Errorf("GitHub Gist file selector %q did not match a file", target.Fragment)
742
+ }
743
+ file := files[matched]
744
+ if file.Truncated && file.RawURL != "" {
745
+ if raw, ok := fetchGitHubGistRaw(ctx, client, file.RawURL); ok {
746
+ file.Content = raw
747
+ file.Truncated = false
748
+ }
749
+ }
750
+ return renderGitHubSelectedGistFile(target, gist, file, selector), nil
751
+ }
752
+ comments, availability, err := fetchGitHubGistCommentPage(ctx, client, target.Name)
753
+ if err != nil {
754
+ return "", err
755
+ }
756
+ return renderGitHubGistOverview(target, gist, files, comments, availability), nil
757
+ }
758
+
759
+ func sortedGitHubGistFiles(fileMap map[string]githubGistFile) []githubGistFile {
760
+ files := make([]githubGistFile, 0, len(fileMap))
761
+ for _, file := range fileMap {
762
+ files = append(files, file)
763
+ }
764
+ sort.SliceStable(files, func(i, j int) bool {
765
+ left := strings.ToLower(files[i].Filename)
766
+ right := strings.ToLower(files[j].Filename)
767
+ if left == right {
768
+ return files[i].Filename < files[j].Filename
769
+ }
770
+ return left < right
771
+ })
772
+ return files
773
+ }
774
+
775
+ func parseGistCommentSelector(fragment string) (int64, bool, error) {
776
+ if fragment == "" || !strings.HasPrefix(fragment, "gistcomment-") {
777
+ return 0, false, nil
778
+ }
779
+ match := gistCommentFragmentRE.FindStringSubmatch(fragment)
780
+ if match == nil {
781
+ return 0, true, fmt.Errorf("invalid GitHub Gist comment selector %q", fragment)
782
+ }
783
+ id, err := strconv.ParseInt(match[1], 10, 64)
784
+ if err != nil || id <= 0 {
785
+ return 0, true, fmt.Errorf("invalid GitHub Gist comment selector %q", fragment)
786
+ }
787
+ return id, true, nil
788
+ }
789
+
790
+ func fetchGitHubGistCommentPage(ctx context.Context, client *GitHubClient, gistID string) ([]githubGistComment, githubGistCommentsAvailability, error) {
791
+ endpoint := fmt.Sprintf("/gists/%s/comments?per_page=30", url.PathEscape(gistID))
792
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
793
+ if err != nil {
794
+ return nil, githubGistCommentsAvailability{}, err
795
+ }
796
+ var comments []githubGistComment
797
+ if err := json.Unmarshal(resp.Body, &comments); err != nil {
798
+ return nil, githubGistCommentsAvailability{}, fmt.Errorf("decode GitHub Gist comments: %w", err)
799
+ }
800
+ return comments, githubGistCommentsAvailability{ProviderMore: resp.Links()["next"] != ""}, nil
801
+ }
802
+
803
+ func fetchGitHubGistComment(ctx context.Context, client *GitHubClient, gistID string, commentID int64) (githubGistComment, error) {
804
+ endpoint := fmt.Sprintf("/gists/%s/comments/%d", url.PathEscape(gistID), commentID)
805
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
806
+ if err != nil {
807
+ return githubGistComment{}, err
808
+ }
809
+ var comment githubGistComment
810
+ if err := json.Unmarshal(resp.Body, &comment); err != nil {
811
+ return githubGistComment{}, fmt.Errorf("decode GitHub Gist comment: %w", err)
812
+ }
813
+ if comment.ID != commentID {
814
+ return githubGistComment{}, fmt.Errorf("GitHub Gist comment response ID %d did not match selector %d", comment.ID, commentID)
815
+ }
816
+ if comment.APIURL != "" {
817
+ parsed, err := url.Parse(comment.APIURL)
818
+ parts := splitGitHubPath(parsed.Path)
819
+ if err != nil || len(parts) < 4 || parts[len(parts)-4] != "gists" || parts[len(parts)-3] != gistID || parts[len(parts)-2] != "comments" || parts[len(parts)-1] != strconv.FormatInt(commentID, 10) {
820
+ return githubGistComment{}, fmt.Errorf("GitHub Gist comment %d returned unexpected API identity %q", commentID, comment.APIURL)
821
+ }
822
+ }
823
+ return comment, nil
824
+ }
825
+
826
+ func fetchGitHubGistRaw(ctx context.Context, client *GitHubClient, rawURL string) (string, bool) {
827
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
828
+ if err != nil {
829
+ return "", false
830
+ }
831
+ if strings.TrimSpace(client.userAgent) != "" {
832
+ req.Header.Set("User-Agent", client.userAgent)
833
+ }
834
+ resp, err := client.httpClient.Do(req)
835
+ if err != nil {
836
+ return "", false
837
+ }
838
+ defer resp.Body.Close()
839
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 || resp.ContentLength > githubBlobMaxBytes {
840
+ return "", false
841
+ }
842
+ body, err := io.ReadAll(io.LimitReader(resp.Body, githubBlobMaxBytes+1))
843
+ if err != nil || int64(len(body)) > githubBlobMaxBytes || !utf8.Valid(body) {
844
+ return "", false
845
+ }
846
+ return string(body), true
847
+ }
848
+
849
+ func parseGistFileSelector(fragment string) (gistFileSelector, bool, error) {
850
+ if fragment == "" {
851
+ return gistFileSelector{}, false, nil
852
+ }
853
+ match := gistLineSuffixRE.FindStringSubmatch(fragment)
854
+ if match == nil {
855
+ return gistFileSelector{}, true, fmt.Errorf("GitHub Gist fragment %q is not a supported file/line selector", fragment)
856
+ }
857
+ selector := gistFileSelector{Slug: match[1]}
858
+ if match[2] != "" {
859
+ selector.Start, _ = strconv.Atoi(match[2])
860
+ selector.End = selector.Start
861
+ if match[3] != "" {
862
+ selector.End, _ = strconv.Atoi(match[3])
863
+ }
864
+ if selector.Start <= 0 || selector.End < selector.Start {
865
+ return gistFileSelector{}, true, fmt.Errorf("invalid GitHub Gist line selector %q", fragment)
866
+ }
867
+ }
868
+ return selector, true, nil
869
+ }
870
+
871
+ func gistFileSlug(filename string) string {
872
+ var b strings.Builder
873
+ b.WriteString("file-")
874
+ lastDash := false
875
+ for _, r := range strings.ToLower(filename) {
876
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
877
+ b.WriteRune(r)
878
+ lastDash = false
879
+ continue
880
+ }
881
+ if !lastDash {
882
+ b.WriteByte('-')
883
+ lastDash = true
884
+ }
885
+ }
886
+ return strings.TrimSuffix(b.String(), "-")
887
+ }
888
+
889
+ func gistCanonicalURL(target *GitHubTarget, gist githubGist) string {
890
+ if strings.TrimSpace(gist.HTMLURL) != "" {
891
+ return gist.HTMLURL
892
+ }
893
+ owner := target.Owner
894
+ if owner == "" && gist.Owner.Login != "" {
895
+ owner = gist.Owner.Login
896
+ }
897
+ if owner != "" {
898
+ return fmt.Sprintf("https://gist.github.com/%s/%s", escapePathPreservingSlashes(owner), url.PathEscape(target.Name))
899
+ }
900
+ return "https://gist.github.com/" + url.PathEscape(target.Name)
901
+ }
902
+
903
+ func gistCommentCanonicalURL(target *GitHubTarget, gist githubGist, comment githubGistComment) string {
904
+ if strings.TrimSpace(comment.HTMLURL) != "" {
905
+ return comment.HTMLURL
906
+ }
907
+ return fmt.Sprintf("%s#gistcomment-%d", gistCanonicalURL(target, gist), comment.ID)
908
+ }
909
+
910
+ func renderGitHubGistOverview(target *GitHubTarget, gist githubGist, files []githubGistFile, comments []githubGistComment, availability githubGistCommentsAvailability) string {
911
+ fileLimit := minInt(12, len(files))
912
+ commentLimit := minInt(8, len(comments))
913
+ revisionLimit := minInt(8, len(gist.History))
914
+ filePreviewRunes := 220
915
+ commentPreviewRunes := 180
916
+ for {
917
+ out := renderGitHubGistOverviewWithLimits(target, gist, files, comments, availability, fileLimit, commentLimit, revisionLimit, filePreviewRunes, commentPreviewRunes)
918
+ if githubOverviewFits(out) {
919
+ return out
920
+ }
921
+ switch {
922
+ case fileLimit > 6:
923
+ fileLimit--
924
+ case commentLimit > 4:
925
+ commentLimit--
926
+ case filePreviewRunes > 120:
927
+ filePreviewRunes -= 20
928
+ case commentPreviewRunes > 120:
929
+ commentPreviewRunes -= 20
930
+ case revisionLimit > 4:
931
+ revisionLimit--
932
+ case fileLimit > 1:
933
+ fileLimit--
934
+ case commentLimit > 1:
935
+ commentLimit--
936
+ default:
937
+ return out
938
+ }
939
+ }
940
+ }
941
+
942
+ func renderGitHubGistOverviewWithLimits(target *GitHubTarget, gist githubGist, files []githubGistFile, comments []githubGistComment, availability githubGistCommentsAvailability, fileLimit, commentLimit, revisionLimit, filePreviewRunes, commentPreviewRunes int) string {
943
+ fileLimit = minInt(fileLimit, len(files))
944
+ commentLimit = minInt(commentLimit, len(comments))
945
+ revisionLimit = minInt(revisionLimit, len(gist.History))
946
+ canonicalURL := gistCanonicalURL(target, gist)
947
+ reportedComments := gist.Comments
948
+ if reportedComments < len(comments) {
949
+ reportedComments = len(comments)
950
+ }
951
+ lines := []string{
952
+ "---",
953
+ "gist: " + yamlScalar(gist.ID),
954
+ fmt.Sprintf("public: %t", gist.Public),
955
+ "overview: true",
956
+ fmt.Sprintf("files_returned: %d", len(files)),
957
+ fmt.Sprintf("files_indexed: %d", fileLimit),
958
+ fmt.Sprintf("files_local_omitted: %d", len(files)-fileLimit),
959
+ fmt.Sprintf("comments_reported: %d", reportedComments),
960
+ fmt.Sprintf("comments_returned: %d", len(comments)),
961
+ fmt.Sprintf("comments_indexed: %d", commentLimit),
962
+ fmt.Sprintf("comments_local_omitted: %d", len(comments)-commentLimit),
963
+ fmt.Sprintf("revisions_returned: %d", len(gist.History)),
964
+ fmt.Sprintf("revisions_indexed: %d", revisionLimit),
965
+ fmt.Sprintf("revisions_local_omitted: %d", len(gist.History)-revisionLimit),
966
+ }
967
+ if availability.ProviderMore {
968
+ lines = append(lines, "comments_provider_more_available: true")
969
+ }
970
+ if target.Owner != "" {
971
+ lines = append(lines, "owner: "+yamlScalar("@"+target.Owner))
972
+ }
973
+ if len(target.Tail) == 1 {
974
+ lines = append(lines, "revision: "+yamlScalar(target.Tail[0]))
975
+ }
976
+ if gist.Description != nil && strings.TrimSpace(*gist.Description) != "" {
977
+ lines = append(lines, "description: "+yamlScalar(*gist.Description))
978
+ }
979
+ if gist.CreatedAt != "" {
980
+ lines = append(lines, "created: "+yamlScalar(gist.CreatedAt))
981
+ }
982
+ if gist.UpdatedAt != "" {
983
+ lines = append(lines, "updated: "+yamlScalar(gist.UpdatedAt))
984
+ }
985
+ lines = append(lines, "url: "+yamlScalar(canonicalURL), "---", "", "# Gist "+gist.ID, "", "## File index", "")
986
+ if len(files) == 0 {
987
+ lines = append(lines, "_No files returned by GitHub._")
988
+ }
989
+ for _, file := range files[:fileLimit] {
990
+ selectorURL := canonicalURL + "#" + gistFileSlug(file.Filename)
991
+ line := fmt.Sprintf("- [%s](%s)", escapeMarkdownLinkText(file.Filename), selectorURL)
992
+ meta := []string{}
993
+ if file.Language != "" {
994
+ meta = append(meta, file.Language)
995
+ }
996
+ if file.Size > 0 {
997
+ meta = append(meta, fmt.Sprintf("%d bytes", file.Size))
998
+ }
999
+ if file.Truncated {
1000
+ meta = append(meta, "API content truncated")
1001
+ }
1002
+ if len(meta) > 0 {
1003
+ line += " — " + strings.Join(meta, " · ")
1004
+ }
1005
+ lines = append(lines, line)
1006
+ content := file.Content
1007
+ if content != "" && !file.Truncated {
1008
+ preview, truncated := githubOverviewPreview(content, filePreviewRunes)
1009
+ lines = append(lines, " Preview: `"+strings.ReplaceAll(strings.ReplaceAll(preview, "`", "'"), "\n", " ⏎ ")+"`")
1010
+ if truncated {
1011
+ lines = append(lines, " Full file: "+selectorURL)
1012
+ }
1013
+ }
1014
+ if file.RawURL != "" {
1015
+ lines = append(lines, " Raw: "+file.RawURL)
1016
+ }
1017
+ }
1018
+ if note := githubLocalOmissionNote("Gist files returned by GitHub", len(files)-fileLimit); note != "" {
1019
+ lines = append(lines, "", note)
1020
+ }
1021
+ lines = append(lines, "", "## Comment index", "")
1022
+ if len(comments) == 0 {
1023
+ lines = append(lines, "_No Gist comments returned on this provider page._")
1024
+ }
1025
+ for _, comment := range comments[:commentLimit] {
1026
+ commentURL := gistCommentCanonicalURL(target, gist, comment)
1027
+ heading := fmt.Sprintf("- [Comment %d](%s)", comment.ID, commentURL)
1028
+ meta := []string{}
1029
+ if comment.User.Login != "" {
1030
+ meta = append(meta, "@"+comment.User.Login)
1031
+ }
1032
+ if comment.CreatedAt != "" {
1033
+ meta = append(meta, comment.CreatedAt)
1034
+ }
1035
+ if len(meta) > 0 {
1036
+ heading += " — " + strings.Join(meta, " · ")
1037
+ }
1038
+ lines = append(lines, heading)
1039
+ body := ""
1040
+ if comment.Body != nil {
1041
+ body = strings.TrimSpace(stripInvisibleHTMLComments(*comment.Body))
1042
+ }
1043
+ if body == "" {
1044
+ body = "_Gist comment body is unavailable or empty._"
1045
+ }
1046
+ preview, truncated := githubOverviewPreview(body, commentPreviewRunes)
1047
+ lines = append(lines, " "+strings.ReplaceAll(preview, "\n", " "))
1048
+ if truncated {
1049
+ lines = append(lines, " Exact comment: "+commentURL)
1050
+ }
1051
+ }
1052
+ if note := githubLocalOmissionNote("Gist comments returned on this provider page", len(comments)-commentLimit); note != "" {
1053
+ lines = append(lines, "", note)
1054
+ }
1055
+ if availability.ProviderMore || reportedComments > len(comments) {
1056
+ lines = append(lines, "", "> More Gist comments exist upstream beyond the provider page fetched for this overview; copy a `#gistcomment-...` URL above for an exact comment read.")
1057
+ }
1058
+ if len(gist.History) > 0 {
1059
+ lines = append(lines, "", "## Revision index", "")
1060
+ for _, revision := range gist.History[:revisionLimit] {
1061
+ line := "- `" + shortSHA(revision.Version) + "`"
1062
+ if revision.CommittedAt != "" {
1063
+ line += " — " + revision.CommittedAt
1064
+ }
1065
+ line += fmt.Sprintf(" — +%d -%d (%d changes)", revision.ChangeStatus.Additions, revision.ChangeStatus.Deletions, revision.ChangeStatus.Total)
1066
+ lines = append(lines, line)
1067
+ }
1068
+ if note := githubLocalOmissionNote("Gist revisions returned by GitHub", len(gist.History)-revisionLimit); note != "" {
1069
+ lines = append(lines, "", note)
1070
+ }
1071
+ }
1072
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1073
+ }
1074
+
1075
+ func renderGitHubSelectedGistFile(target *GitHubTarget, gist githubGist, file githubGistFile, selector gistFileSelector) string {
1076
+ lines := []string{
1077
+ "---",
1078
+ "gist: " + yamlScalar(gist.ID),
1079
+ "file: " + yamlScalar(file.Filename),
1080
+ "selector: " + yamlScalar(target.Fragment),
1081
+ }
1082
+ if len(target.Tail) == 1 {
1083
+ lines = append(lines, "revision: "+yamlScalar(target.Tail[0]))
1084
+ }
1085
+ if file.Language != "" {
1086
+ lines = append(lines, "language: "+yamlScalar(file.Language))
1087
+ }
1088
+ if file.Size > 0 {
1089
+ lines = append(lines, fmt.Sprintf("size: %d", file.Size))
1090
+ }
1091
+ if file.RawURL != "" {
1092
+ lines = append(lines, "raw_url: "+yamlScalar(file.RawURL))
1093
+ }
1094
+ lines = append(lines, "url: "+yamlScalar(gistCanonicalURL(target, gist)+"#"+target.Fragment), "---", "", "# "+file.Filename, "")
1095
+ content := file.Content
1096
+ if selector.Start > 0 {
1097
+ selected, err := selectTextLines(content, selector.Start, selector.End)
1098
+ if err != nil {
1099
+ lines = append(lines, "_Selected line range is out of range for the Gist file._")
1100
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1101
+ }
1102
+ content = selected
1103
+ lines = append(lines, fmt.Sprintf("**Lines %d-%d:**", selector.Start, selector.End), "")
1104
+ }
1105
+ if file.Truncated {
1106
+ lines = append(lines, "_GitHub marked this API file content truncated; it is not presented as complete._")
1107
+ if file.RawURL != "" {
1108
+ lines = append(lines, "Full raw file: "+file.RawURL)
1109
+ }
1110
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1111
+ }
1112
+ fence := "```"
1113
+ if strings.Contains(content, "```") {
1114
+ fence = "````"
1115
+ }
1116
+ lines = append(lines, fence, content, fence)
1117
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1118
+ }
1119
+
1120
+ func renderGitHubSelectedGistComment(target *GitHubTarget, gist githubGist, comment githubGistComment) string {
1121
+ commentURL := gistCommentCanonicalURL(target, gist, comment)
1122
+ lines := []string{
1123
+ "---",
1124
+ "gist: " + yamlScalar(gist.ID),
1125
+ "selector: " + yamlScalar(target.Fragment),
1126
+ fmt.Sprintf("comment_id: %d", comment.ID),
1127
+ "url: " + yamlScalar(commentURL),
1128
+ }
1129
+ if comment.User.Login != "" {
1130
+ lines = append(lines, "author: "+yamlScalar("@"+comment.User.Login))
1131
+ }
1132
+ if comment.CreatedAt != "" {
1133
+ lines = append(lines, "created: "+yamlScalar(comment.CreatedAt))
1134
+ }
1135
+ if comment.UpdatedAt != "" {
1136
+ lines = append(lines, "updated: "+yamlScalar(comment.UpdatedAt))
1137
+ }
1138
+ lines = append(lines, "---", "", fmt.Sprintf("# Gist comment %d", comment.ID), "", "From: "+gistCanonicalURL(target, gist), "")
1139
+ body := ""
1140
+ if comment.Body != nil {
1141
+ body = strings.TrimSpace(stripInvisibleHTMLComments(*comment.Body))
1142
+ }
1143
+ if body == "" {
1144
+ body = "_Gist comment body is unavailable or empty._"
1145
+ }
1146
+ lines = append(lines, body)
1147
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1148
+ }
1149
+
1150
+ func selectTextLines(content string, start, end int) (string, error) {
1151
+ lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
1152
+ if start <= 0 || end < start || start > len(lines) || end > len(lines) {
1153
+ return "", fmt.Errorf("line range out of bounds")
1154
+ }
1155
+ return strings.Join(lines[start-1:end], "\n"), nil
1156
+ }