webctx 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +97 -0
- package/CONTRIBUTORS.md +126 -0
- package/LICENSE +202 -0
- package/Makefile +69 -0
- package/README.md +121 -0
- package/bin/webctx.js +28 -0
- package/cmd/webctx/main.go +11 -0
- package/docs/porting-status.md +173 -0
- package/go.mod +3 -0
- package/internal/app/app.go +139 -0
- package/internal/app/app_test.go +194 -0
- package/internal/app/github.go +2084 -0
- package/internal/app/github_acceptance_test.go +166 -0
- package/internal/app/github_actions.go +1008 -0
- package/internal/app/github_actions_test.go +484 -0
- package/internal/app/github_activity_deployments.go +503 -0
- package/internal/app/github_activity_deployments_test.go +321 -0
- package/internal/app/github_commits.go +1004 -0
- package/internal/app/github_commits_test.go +503 -0
- package/internal/app/github_discussions_gists.go +1156 -0
- package/internal/app/github_discussions_gists_test.go +425 -0
- package/internal/app/github_issues.go +1616 -0
- package/internal/app/github_issues_test.go +731 -0
- package/internal/app/github_packages_projects.go +429 -0
- package/internal/app/github_packages_projects_test.go +168 -0
- package/internal/app/github_phase9_overview_stress_test.go +178 -0
- package/internal/app/github_pull_views.go +1141 -0
- package/internal/app/github_pull_views_test.go +603 -0
- package/internal/app/github_pulls.go +1459 -0
- package/internal/app/github_pulls_test.go +715 -0
- package/internal/app/github_refs.go +621 -0
- package/internal/app/github_refs_test.go +260 -0
- package/internal/app/github_search_profiles.go +505 -0
- package/internal/app/github_search_profiles_test.go +352 -0
- package/internal/app/github_test.go +888 -0
- package/internal/app/scrape.go +264 -0
- package/internal/app/tools.go +670 -0
- package/internal/buildinfo/buildinfo.go +16 -0
- package/package.json +66 -0
- package/scripts/postinstall.js +137 -0
|
@@ -0,0 +1,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
|
+
}
|