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,1616 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "net/url"
9
+ "sort"
10
+ "strconv"
11
+ "strings"
12
+ "time"
13
+ )
14
+
15
+ type githubUser struct {
16
+ Login string `json:"login"`
17
+ HTMLURL string `json:"html_url"`
18
+ Type string `json:"type"`
19
+ }
20
+
21
+ type githubIssueLabel struct {
22
+ Name string `json:"name"`
23
+ Color string `json:"color"`
24
+ Description string `json:"description"`
25
+ }
26
+
27
+ type githubIssueMilestone struct {
28
+ Number int `json:"number"`
29
+ State string `json:"state"`
30
+ Title string `json:"title"`
31
+ Description string `json:"description"`
32
+ HTMLURL string `json:"html_url"`
33
+ Creator githubUser `json:"creator"`
34
+ OpenIssues int `json:"open_issues"`
35
+ ClosedIssues int `json:"closed_issues"`
36
+ CreatedAt string `json:"created_at"`
37
+ UpdatedAt string `json:"updated_at"`
38
+ ClosedAt string `json:"closed_at"`
39
+ DueOn string `json:"due_on"`
40
+ }
41
+
42
+ type githubIssueType struct {
43
+ Name string `json:"name"`
44
+ Description string `json:"description"`
45
+ }
46
+
47
+ type githubPullMarker struct {
48
+ HTMLURL string `json:"html_url"`
49
+ }
50
+
51
+ type githubIssue struct {
52
+ ID int64 `json:"id"`
53
+ Number int `json:"number"`
54
+ State string `json:"state"`
55
+ StateReason string `json:"state_reason"`
56
+ Title string `json:"title"`
57
+ Body *string `json:"body"`
58
+ HTMLURL string `json:"html_url"`
59
+ User githubUser `json:"user"`
60
+ AuthorAssociation string `json:"author_association"`
61
+ Labels []githubIssueLabel `json:"labels"`
62
+ Assignees []githubUser `json:"assignees"`
63
+ Milestone *githubIssueMilestone `json:"milestone"`
64
+ Draft bool `json:"draft"`
65
+ Locked bool `json:"locked"`
66
+ ActiveLockReason string `json:"active_lock_reason"`
67
+ Comments int `json:"comments"`
68
+ CreatedAt string `json:"created_at"`
69
+ UpdatedAt string `json:"updated_at"`
70
+ ClosedAt string `json:"closed_at"`
71
+ PullRequest *githubPullMarker `json:"pull_request"`
72
+ PinnedComment json.RawMessage `json:"pinned_comment"`
73
+ Type json.RawMessage `json:"type"`
74
+ }
75
+
76
+ type githubIssueComment struct {
77
+ ID int64 `json:"id"`
78
+ Body *string `json:"body"`
79
+ HTMLURL string `json:"html_url"`
80
+ IssueURL string `json:"issue_url"`
81
+ User githubUser `json:"user"`
82
+ AuthorAssociation string `json:"author_association"`
83
+ CreatedAt string `json:"created_at"`
84
+ UpdatedAt string `json:"updated_at"`
85
+ IsPinned bool `json:"is_pinned"`
86
+ Minimized bool `json:"minimized"`
87
+ MinimizedReason string `json:"minimized_reason"`
88
+ }
89
+
90
+ func (comment *githubIssueComment) UnmarshalJSON(data []byte) error {
91
+ type alias githubIssueComment
92
+ aux := struct {
93
+ Minimized json.RawMessage `json:"minimized"`
94
+ MinimizedReason json.RawMessage `json:"minimized_reason"`
95
+ *alias
96
+ }{alias: (*alias)(comment)}
97
+ if err := json.Unmarshal(data, &aux); err != nil {
98
+ return err
99
+ }
100
+ comment.Minimized, comment.MinimizedReason = decodeGitHubMinimized(aux.Minimized, aux.MinimizedReason)
101
+ return nil
102
+ }
103
+
104
+ type githubIssueFieldValue struct {
105
+ IssueFieldName string `json:"issue_field_name"`
106
+ DataType string `json:"data_type"`
107
+ Value any `json:"value"`
108
+ SingleSelectOption *struct {
109
+ Name string `json:"name"`
110
+ } `json:"single_select_option"`
111
+ MultiSelectOptions []struct {
112
+ Name string `json:"name"`
113
+ } `json:"multi_select_options"`
114
+ }
115
+
116
+ type githubTimelineEvent struct {
117
+ ID int64 `json:"id"`
118
+ Event string `json:"event"`
119
+ Actor githubUser `json:"actor"`
120
+ User githubUser `json:"user"`
121
+ Body *string `json:"body"`
122
+ HTMLURL string `json:"html_url"`
123
+ CreatedAt string `json:"created_at"`
124
+ UpdatedAt string `json:"updated_at"`
125
+ AuthorAssociation string `json:"author_association"`
126
+ Label *githubIssueLabel `json:"label"`
127
+ Milestone *githubIssueMilestone `json:"milestone"`
128
+ Assignee *githubUser `json:"assignee"`
129
+ CommitID string `json:"commit_id"`
130
+ CommitURL string `json:"commit_url"`
131
+ SHA string `json:"sha"`
132
+ Message string `json:"message"`
133
+ State string `json:"state"`
134
+ SubmittedAt string `json:"submitted_at"`
135
+ RequestedReviewer *githubUser `json:"requested_reviewer"`
136
+ ReviewRequester *githubUser `json:"review_requester"`
137
+ RequestedTeam *struct {
138
+ Name string `json:"name"`
139
+ Slug string `json:"slug"`
140
+ } `json:"requested_team"`
141
+ LockReason string `json:"lock_reason"`
142
+ Rename *struct {
143
+ From string `json:"from"`
144
+ To string `json:"to"`
145
+ } `json:"rename"`
146
+ Source *struct {
147
+ Issue *githubIssue `json:"issue"`
148
+ } `json:"source"`
149
+ IsPinned bool `json:"is_pinned"`
150
+ Minimized bool `json:"minimized"`
151
+ MinimizedReason string `json:"minimized_reason"`
152
+ }
153
+
154
+ func (event *githubTimelineEvent) UnmarshalJSON(data []byte) error {
155
+ type alias githubTimelineEvent
156
+ aux := struct {
157
+ Minimized json.RawMessage `json:"minimized"`
158
+ MinimizedReason json.RawMessage `json:"minimized_reason"`
159
+ *alias
160
+ }{alias: (*alias)(event)}
161
+ if err := json.Unmarshal(data, &aux); err != nil {
162
+ return err
163
+ }
164
+ event.Minimized, event.MinimizedReason = decodeGitHubMinimized(aux.Minimized, aux.MinimizedReason)
165
+ return nil
166
+ }
167
+
168
+ func decodeGitHubMinimized(raw, reasonRaw json.RawMessage) (bool, string) {
169
+ minimized := false
170
+ reason := ""
171
+ trimmed := strings.TrimSpace(string(raw))
172
+ switch {
173
+ case trimmed == "", trimmed == "null":
174
+ case trimmed == "true":
175
+ minimized = true
176
+ case trimmed == "false":
177
+ default:
178
+ var object struct {
179
+ Reason json.RawMessage `json:"reason"`
180
+ }
181
+ if json.Unmarshal(raw, &object) == nil && len(object.Reason) > 0 {
182
+ minimized = true
183
+ _ = json.Unmarshal(object.Reason, &reason)
184
+ }
185
+ }
186
+ if strings.TrimSpace(reason) == "" {
187
+ _ = json.Unmarshal(reasonRaw, &reason)
188
+ }
189
+ return minimized, strings.TrimSpace(reason)
190
+ }
191
+
192
+ type githubIssueRelationships struct {
193
+ Parent *githubIssue
194
+ SubIssues []githubIssue
195
+ BlockedBy []githubIssue
196
+ Blocking []githubIssue
197
+ Fields []githubIssueFieldValue
198
+ SubIssuesProviderMore bool
199
+ BlockedByProviderMore bool
200
+ BlockingProviderMore bool
201
+ FieldsProviderMore bool
202
+ }
203
+
204
+ type githubIssueAvailability struct {
205
+ TimelineProviderMore bool
206
+ }
207
+
208
+ func (r githubIssueRelationships) providerMore() bool {
209
+ return r.SubIssuesProviderMore || r.BlockedByProviderMore || r.BlockingProviderMore || r.FieldsProviderMore
210
+ }
211
+
212
+ type githubIssueSearchResponse struct {
213
+ TotalCount int `json:"total_count"`
214
+ IncompleteResults bool `json:"incomplete_results"`
215
+ Items []githubIssue `json:"items"`
216
+ }
217
+
218
+ func readGitHubIssue(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
219
+ if issueID, ok, err := parseIssueBodySelector(target.Fragment); ok {
220
+ if err != nil {
221
+ return "", err
222
+ }
223
+ return readGitHubIssueBody(ctx, client, target, issueID)
224
+ }
225
+ if commentID, ok, err := parseIssueCommentSelector(target.Fragment); ok {
226
+ if err != nil {
227
+ return "", err
228
+ }
229
+ return readGitHubIssueComment(ctx, client, target, commentID)
230
+ }
231
+ if target.Fragment != "" {
232
+ return "", fmt.Errorf("GitHub Issue fragment %q is not a supported native selector", target.Fragment)
233
+ }
234
+
235
+ issue, err := fetchGitHubIssue(ctx, client, target)
236
+ if err != nil {
237
+ return "", err
238
+ }
239
+ if issue.PullRequest != nil {
240
+ canonical := issue.PullRequest.HTMLURL
241
+ if canonical == "" {
242
+ canonical = fmt.Sprintf("https://github.com/%s/%s/pull/%d", target.Owner, target.Repo, target.Number)
243
+ }
244
+ return "", fmt.Errorf("GitHub #%d is a pull request, not an Issue. Canonical URL: %s", target.Number, canonical)
245
+ }
246
+
247
+ timeline, timelineMore, err := fetchGitHubIssueTimelinePage(ctx, client, target)
248
+ if err != nil {
249
+ return "", err
250
+ }
251
+ relationships, err := fetchGitHubIssueRelationships(ctx, client, target)
252
+ if err != nil {
253
+ return "", err
254
+ }
255
+ return renderGitHubIssue(target, issue, timeline, relationships, githubIssueAvailability{TimelineProviderMore: timelineMore}), nil
256
+ }
257
+
258
+ func fetchGitHubIssue(ctx context.Context, client *GitHubClient, target *GitHubTarget) (githubIssue, error) {
259
+ endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
260
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
261
+ if err != nil {
262
+ return githubIssue{}, err
263
+ }
264
+ var issue githubIssue
265
+ if err := json.Unmarshal(resp.Body, &issue); err != nil {
266
+ return githubIssue{}, fmt.Errorf("decode GitHub Issue: %w", err)
267
+ }
268
+ return issue, nil
269
+ }
270
+
271
+ func parseIssueBodySelector(fragment string) (int64, bool, error) {
272
+ if !strings.HasPrefix(fragment, "issue-") || strings.HasPrefix(fragment, "issuecomment-") {
273
+ return 0, false, nil
274
+ }
275
+ raw := strings.TrimPrefix(fragment, "issue-")
276
+ id, err := strconv.ParseInt(raw, 10, 64)
277
+ if err != nil || id <= 0 {
278
+ return 0, true, fmt.Errorf("invalid GitHub Issue-body selector %q", fragment)
279
+ }
280
+ return id, true, nil
281
+ }
282
+
283
+ func readGitHubIssueBody(ctx context.Context, client *GitHubClient, target *GitHubTarget, issueID int64) (string, error) {
284
+ issue, err := fetchGitHubIssue(ctx, client, target)
285
+ if err != nil {
286
+ return "", err
287
+ }
288
+ if issue.PullRequest != nil {
289
+ canonical := issue.PullRequest.HTMLURL
290
+ if canonical == "" {
291
+ canonical = fmt.Sprintf("https://github.com/%s/%s/pull/%d", target.Owner, target.Repo, target.Number)
292
+ }
293
+ return "", fmt.Errorf("GitHub #%d is a pull request, not an Issue. Canonical URL: %s", target.Number, canonical)
294
+ }
295
+ if issue.ID != issueID {
296
+ return "", fmt.Errorf("GitHub Issue body selector issue-%d does not belong to %s/%s#%d (Issue id %d)", issueID, target.Owner, target.Repo, target.Number, issue.ID)
297
+ }
298
+ return renderGitHubIssueBody(target, issue), nil
299
+ }
300
+
301
+ func renderGitHubIssueBody(target *GitHubTarget, issue githubIssue) string {
302
+ lines := []string{
303
+ "---",
304
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
305
+ fmt.Sprintf("issue: %d", issue.Number),
306
+ fmt.Sprintf("issue_id: %d", issue.ID),
307
+ "title: " + yamlScalar(issue.Title),
308
+ }
309
+ if issue.User.Login != "" {
310
+ lines = append(lines, "author: "+yamlScalar("@"+issue.User.Login))
311
+ }
312
+ if issue.HTMLURL != "" {
313
+ lines = append(lines, "url: "+yamlScalar(issue.HTMLURL+"#issue-"+strconv.FormatInt(issue.ID, 10)))
314
+ }
315
+ lines = append(lines, "---", "", fmt.Sprintf("# Description of %s/%s#%d", target.Owner, target.Repo, issue.Number), "")
316
+ if issue.Body == nil || strings.TrimSpace(stripInvisibleHTMLComments(*issue.Body)) == "" {
317
+ lines = append(lines, "_No description provided._")
318
+ } else {
319
+ lines = append(lines, stripInvisibleHTMLComments(*issue.Body))
320
+ }
321
+ return strings.TrimSpace(strings.Join(lines, "\n"))
322
+ }
323
+
324
+ func parseIssueCommentSelector(fragment string) (int64, bool, error) {
325
+ if !strings.HasPrefix(fragment, "issuecomment-") {
326
+ return 0, false, nil
327
+ }
328
+ raw := strings.TrimPrefix(fragment, "issuecomment-")
329
+ id, err := strconv.ParseInt(raw, 10, 64)
330
+ if err != nil || id <= 0 {
331
+ return 0, true, fmt.Errorf("invalid GitHub issue-comment selector %q", fragment)
332
+ }
333
+ return id, true, nil
334
+ }
335
+
336
+ func readGitHubIssueComment(ctx context.Context, client *GitHubClient, target *GitHubTarget, commentID int64) (string, error) {
337
+ endpoint := fmt.Sprintf("/repos/%s/%s/issues/comments/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), commentID)
338
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
339
+ if err != nil {
340
+ return "", err
341
+ }
342
+ var comment githubIssueComment
343
+ if err := json.Unmarshal(resp.Body, &comment); err != nil {
344
+ return "", fmt.Errorf("decode GitHub Issue comment: %w", err)
345
+ }
346
+ wantSuffix := fmt.Sprintf("/repos/%s/%s/issues/%d", target.Owner, target.Repo, target.Number)
347
+ if comment.IssueURL != "" && !strings.HasSuffix(strings.TrimRight(comment.IssueURL, "/"), wantSuffix) {
348
+ return "", fmt.Errorf("GitHub comment %d does not belong to %s/%s#%d", commentID, target.Owner, target.Repo, target.Number)
349
+ }
350
+ return renderGitHubIssueComment(target, comment), nil
351
+ }
352
+
353
+ func fetchGitHubIssueTimelinePage(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubTimelineEvent, bool, error) {
354
+ endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d/timeline?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
355
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
356
+ if err != nil {
357
+ return nil, false, err
358
+ }
359
+ var events []githubTimelineEvent
360
+ if err := json.Unmarshal(resp.Body, &events); err != nil {
361
+ return nil, false, fmt.Errorf("decode GitHub Issue timeline: %w", err)
362
+ }
363
+ return events, strings.TrimSpace(resp.Links()["next"]) != "", nil
364
+ }
365
+
366
+ func fetchGitHubIssueRelationships(ctx context.Context, client *GitHubClient, target *GitHubTarget) (githubIssueRelationships, error) {
367
+ base := fmt.Sprintf("/repos/%s/%s/issues/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
368
+ out := githubIssueRelationships{}
369
+
370
+ parentResp, err := client.REST(ctx, http.MethodGet, base+"/parent", "application/vnd.github+json")
371
+ if err != nil {
372
+ if !isOptionalGitHubRelationshipMiss(err) {
373
+ return out, err
374
+ }
375
+ } else {
376
+ var parent githubIssue
377
+ if err := json.Unmarshal(parentResp.Body, &parent); err != nil {
378
+ return out, fmt.Errorf("decode GitHub parent Issue: %w", err)
379
+ }
380
+ out.Parent = &parent
381
+ }
382
+
383
+ var errFetch error
384
+ out.SubIssues, out.SubIssuesProviderMore, errFetch = fetchOptionalIssuePage(ctx, client, base+"/sub_issues?per_page=100")
385
+ if errFetch != nil {
386
+ return out, errFetch
387
+ }
388
+ out.BlockedBy, out.BlockedByProviderMore, errFetch = fetchOptionalIssuePage(ctx, client, base+"/dependencies/blocked_by?per_page=100")
389
+ if errFetch != nil {
390
+ return out, errFetch
391
+ }
392
+ out.Blocking, out.BlockingProviderMore, errFetch = fetchOptionalIssuePage(ctx, client, base+"/dependencies/blocking?per_page=100")
393
+ if errFetch != nil {
394
+ return out, errFetch
395
+ }
396
+ out.Fields, out.FieldsProviderMore, errFetch = fetchOptionalIssueFieldPage(ctx, client, base+"/issue-field-values?per_page=100")
397
+ if errFetch != nil {
398
+ return out, errFetch
399
+ }
400
+ return out, nil
401
+ }
402
+
403
+ func isOptionalGitHubRelationshipMiss(err error) bool {
404
+ var ghErr *GitHubError
405
+ if !asGitHubError(err, &ghErr) {
406
+ return false
407
+ }
408
+ return ghErr.Kind == GitHubErrorNotFound || ghErr.Kind == GitHubErrorGone
409
+ }
410
+
411
+ func fetchOptionalIssuePage(ctx context.Context, client *GitHubClient, endpoint string) ([]githubIssue, bool, error) {
412
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
413
+ if err != nil {
414
+ if isOptionalGitHubRelationshipMiss(err) {
415
+ return nil, false, nil
416
+ }
417
+ return nil, false, err
418
+ }
419
+ var out []githubIssue
420
+ if err := json.Unmarshal(resp.Body, &out); err != nil {
421
+ return nil, false, fmt.Errorf("decode GitHub Issue relationship: %w", err)
422
+ }
423
+ return out, strings.TrimSpace(resp.Links()["next"]) != "", nil
424
+ }
425
+
426
+ func fetchOptionalIssueFieldPage(ctx context.Context, client *GitHubClient, endpoint string) ([]githubIssueFieldValue, bool, error) {
427
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
428
+ if err != nil {
429
+ if isOptionalGitHubRelationshipMiss(err) {
430
+ return nil, false, nil
431
+ }
432
+ return nil, false, err
433
+ }
434
+ var out []githubIssueFieldValue
435
+ if err := json.Unmarshal(resp.Body, &out); err != nil {
436
+ return nil, false, fmt.Errorf("decode GitHub Issue field values: %w", err)
437
+ }
438
+ return out, strings.TrimSpace(resp.Links()["next"]) != "", nil
439
+ }
440
+
441
+ func renderGitHubIssue(target *GitHubTarget, issue githubIssue, timeline []githubTimelineEvent, rel githubIssueRelationships, availability githubIssueAvailability) string {
442
+ complete := renderGitHubIssueComplete(target, issue, timeline, rel)
443
+ if !availability.TimelineProviderMore && !rel.providerMore() && githubOverviewFits(complete) {
444
+ return complete
445
+ }
446
+ return renderGitHubIssueOverview(target, issue, timeline, rel, availability)
447
+ }
448
+
449
+ func renderGitHubIssueComplete(target *GitHubTarget, issue githubIssue, timeline []githubTimelineEvent, rel githubIssueRelationships) string {
450
+ lines := []string{
451
+ "---",
452
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
453
+ fmt.Sprintf("number: %d", issue.Number),
454
+ "state: " + yamlScalar(issue.State),
455
+ "title: " + yamlScalar(issue.Title),
456
+ }
457
+ if issue.StateReason != "" {
458
+ lines = append(lines, "state_reason: "+yamlScalar(issue.StateReason))
459
+ }
460
+ if issue.User.Login != "" {
461
+ lines = append(lines, "author: "+yamlScalar("@"+issue.User.Login))
462
+ }
463
+ if issue.CreatedAt != "" {
464
+ lines = append(lines, "created: "+yamlScalar(issue.CreatedAt))
465
+ }
466
+ if issue.UpdatedAt != "" {
467
+ lines = append(lines, "updated: "+yamlScalar(issue.UpdatedAt))
468
+ }
469
+ if issue.ClosedAt != "" {
470
+ lines = append(lines, "closed: "+yamlScalar(issue.ClosedAt))
471
+ }
472
+ if issue.Locked {
473
+ lines = append(lines, "locked: true")
474
+ if issue.ActiveLockReason != "" {
475
+ lines = append(lines, "lock_reason: "+yamlScalar(issue.ActiveLockReason))
476
+ }
477
+ }
478
+ if labels := issueLabelNames(issue.Labels); len(labels) > 0 {
479
+ encoded, _ := json.Marshal(labels)
480
+ lines = append(lines, "labels: "+string(encoded))
481
+ }
482
+ if assignees := githubUserLogins(issue.Assignees); len(assignees) > 0 {
483
+ encoded, _ := json.Marshal(assignees)
484
+ lines = append(lines, "assignees: "+string(encoded))
485
+ }
486
+ if issue.Milestone != nil && issue.Milestone.Title != "" {
487
+ lines = append(lines, "milestone: "+yamlScalar(issue.Milestone.Title))
488
+ }
489
+ if issueType := githubIssueTypeName(issue.Type); issueType != "" {
490
+ lines = append(lines, "type: "+yamlScalar(issueType))
491
+ }
492
+ lines = append(lines, fmt.Sprintf("comments: %d", issue.Comments))
493
+ if issue.HTMLURL != "" {
494
+ lines = append(lines, "url: "+yamlScalar(issue.HTMLURL))
495
+ }
496
+ lines = append(lines, "---", "", fmt.Sprintf("# #%d %s", issue.Number, issue.Title), "", "## Body", "")
497
+ if issue.Body == nil || strings.TrimSpace(stripInvisibleHTMLComments(*issue.Body)) == "" {
498
+ lines = append(lines, "_No description provided._")
499
+ } else {
500
+ lines = append(lines, stripInvisibleHTMLComments(*issue.Body))
501
+ }
502
+
503
+ relLines := renderIssueRelationships(rel)
504
+ if len(relLines) > 0 {
505
+ lines = append(lines, "", "## Relationships", "")
506
+ lines = append(lines, relLines...)
507
+ }
508
+
509
+ pinned := decodePinnedIssueComment(issue.PinnedComment)
510
+ pinnedRenderedInTimeline := false
511
+ if pinned != nil {
512
+ for _, event := range timeline {
513
+ if event.Event == "commented" && event.ID == pinned.ID {
514
+ pinnedRenderedInTimeline = true
515
+ break
516
+ }
517
+ }
518
+ }
519
+ if pinned != nil && !pinnedRenderedInTimeline {
520
+ lines = append(lines, "", "## Pinned comment", "")
521
+ lines = append(lines, renderIssueCommentBody(*pinned, true)...)
522
+ }
523
+
524
+ lines = append(lines, "", "## Timeline", "")
525
+ visible := 0
526
+ for _, event := range timeline {
527
+ if event.Event == "commented" {
528
+ comment := githubIssueComment{
529
+ ID: event.ID,
530
+ Body: event.Body,
531
+ HTMLURL: event.HTMLURL,
532
+ User: event.User,
533
+ AuthorAssociation: event.AuthorAssociation,
534
+ CreatedAt: event.CreatedAt,
535
+ UpdatedAt: event.UpdatedAt,
536
+ IsPinned: event.IsPinned || (pinned != nil && pinned.ID == event.ID),
537
+ Minimized: event.Minimized,
538
+ MinimizedReason: event.MinimizedReason,
539
+ }
540
+ lines = append(lines, renderTimelineComment(comment)...)
541
+ visible++
542
+ continue
543
+ }
544
+ if rendered, ok := renderIssueTimelineState(event); ok {
545
+ lines = append(lines, rendered)
546
+ visible++
547
+ }
548
+ }
549
+ if visible == 0 {
550
+ lines = append(lines, "_No substantive timeline activity._")
551
+ }
552
+
553
+ base := fmt.Sprintf("https://github.com/%s/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo))
554
+ lines = append(lines, "", "## Useful GitHub URLs", "", "- Issue list: "+base+"/issues")
555
+ return strings.TrimSpace(strings.Join(lines, "\n"))
556
+ }
557
+
558
+ func renderGitHubIssueOverview(target *GitHubTarget, issue githubIssue, timeline []githubTimelineEvent, rel githubIssueRelationships, availability githubIssueAvailability) string {
559
+ visible := substantiveIssueTimeline(timeline)
560
+ maxIndexed := len(visible)
561
+ if maxIndexed > 10 {
562
+ maxIndexed = 10
563
+ }
564
+ for indexed := maxIndexed; indexed >= 0; indexed-- {
565
+ out := renderGitHubIssueOverviewWithLimit(target, issue, timeline, visible, rel, availability, indexed)
566
+ if githubOverviewFits(out) {
567
+ return out
568
+ }
569
+ }
570
+ // Mandatory metadata/navigation win over the soft target if an extreme
571
+ // provider title/relationship value alone consumes the whole budget.
572
+ return renderGitHubIssueOverviewWithLimit(target, issue, timeline, visible, rel, availability, 0)
573
+ }
574
+
575
+ func renderGitHubIssueOverviewWithLimit(target *GitHubTarget, issue githubIssue, timeline, visible []githubTimelineEvent, rel githubIssueRelationships, availability githubIssueAvailability, indexedLimit int) string {
576
+ if indexedLimit < 0 {
577
+ indexedLimit = 0
578
+ }
579
+ if indexedLimit > len(visible) {
580
+ indexedLimit = len(visible)
581
+ }
582
+ commentReturned := 0
583
+ for _, event := range timeline {
584
+ if event.Event == "commented" {
585
+ commentReturned++
586
+ }
587
+ }
588
+ commentIndexed := 0
589
+ for _, event := range visible[:indexedLimit] {
590
+ if event.Event == "commented" {
591
+ commentIndexed++
592
+ }
593
+ }
594
+
595
+ lines := []string{
596
+ "---",
597
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
598
+ fmt.Sprintf("number: %d", issue.Number),
599
+ }
600
+ if issue.ID > 0 {
601
+ lines = append(lines, fmt.Sprintf("issue_id: %d", issue.ID))
602
+ }
603
+ titlePreview, titleTruncated := githubOverviewInlinePreview(issue.Title, 180)
604
+ if titleTruncated {
605
+ titlePreview += "…"
606
+ }
607
+ lines = append(lines,
608
+ "state: "+yamlScalar(issue.State),
609
+ "title: "+yamlScalar(titlePreview),
610
+ "overview: true",
611
+ )
612
+ if titleTruncated {
613
+ lines = append(lines, "title_preview_truncated: true")
614
+ }
615
+ if issue.StateReason != "" {
616
+ lines = append(lines, "state_reason: "+yamlScalar(issue.StateReason))
617
+ }
618
+ if issue.User.Login != "" {
619
+ lines = append(lines, "author: "+yamlScalar("@"+issue.User.Login))
620
+ }
621
+ if issue.CreatedAt != "" {
622
+ lines = append(lines, "created: "+yamlScalar(issue.CreatedAt))
623
+ }
624
+ if issue.UpdatedAt != "" {
625
+ lines = append(lines, "updated: "+yamlScalar(issue.UpdatedAt))
626
+ }
627
+ if issue.ClosedAt != "" {
628
+ lines = append(lines, "closed: "+yamlScalar(issue.ClosedAt))
629
+ }
630
+ if issue.Locked {
631
+ lines = append(lines, "locked: true")
632
+ if issue.ActiveLockReason != "" {
633
+ lines = append(lines, "lock_reason: "+yamlScalar(issue.ActiveLockReason))
634
+ }
635
+ }
636
+ if labels := issueLabelNames(issue.Labels); len(labels) > 0 {
637
+ shown := labels[:minInt(5, len(labels))]
638
+ encoded, _ := json.Marshal(shown)
639
+ lines = append(lines, "labels: "+string(encoded))
640
+ if len(labels) > len(shown) {
641
+ lines = append(lines, fmt.Sprintf("labels_local_omitted: %d", len(labels)-len(shown)))
642
+ }
643
+ }
644
+ if assignees := githubUserLogins(issue.Assignees); len(assignees) > 0 {
645
+ shown := assignees[:minInt(5, len(assignees))]
646
+ encoded, _ := json.Marshal(shown)
647
+ lines = append(lines, "assignees: "+string(encoded))
648
+ if len(assignees) > len(shown) {
649
+ lines = append(lines, fmt.Sprintf("assignees_local_omitted: %d", len(assignees)-len(shown)))
650
+ }
651
+ }
652
+ if issue.Milestone != nil && issue.Milestone.Title != "" {
653
+ lines = append(lines, "milestone: "+yamlScalar(issue.Milestone.Title))
654
+ }
655
+ if issueType := githubIssueTypeName(issue.Type); issueType != "" {
656
+ lines = append(lines, "type: "+yamlScalar(issueType))
657
+ }
658
+ lines = append(lines,
659
+ fmt.Sprintf("comments_reported: %d", issue.Comments),
660
+ fmt.Sprintf("comments_returned: %d", commentReturned),
661
+ fmt.Sprintf("comments_indexed: %d", commentIndexed),
662
+ fmt.Sprintf("timeline_items_returned: %d", len(visible)),
663
+ fmt.Sprintf("timeline_items_indexed: %d", indexedLimit),
664
+ fmt.Sprintf("timeline_items_omitted: %d", len(visible)-indexedLimit),
665
+ )
666
+ if availability.TimelineProviderMore || issue.Comments > commentReturned {
667
+ lines = append(lines, "comments_provider_complete: false")
668
+ }
669
+ if availability.TimelineProviderMore {
670
+ lines = append(lines, "timeline_provider_more_available: true")
671
+ }
672
+ if rel.providerMore() {
673
+ lines = append(lines, "relationships_provider_more_available: true")
674
+ }
675
+ if issue.HTMLURL != "" {
676
+ lines = append(lines, "url: "+yamlScalar(issue.HTMLURL))
677
+ }
678
+ lines = append(lines, "---", "", fmt.Sprintf("# #%d %s", issue.Number, titlePreview), "", "> Large Issue overview: subordinate conversation text is previewed/indexed so the default read stays near 5,000 characters.")
679
+
680
+ bodySelector := issueBodySelectorURL(target, issue)
681
+ lines = append(lines, "", "## Body preview", "")
682
+ if issue.Body == nil || strings.TrimSpace(stripInvisibleHTMLComments(*issue.Body)) == "" {
683
+ lines = append(lines, "_No description provided._")
684
+ } else {
685
+ body := stripInvisibleHTMLComments(*issue.Body)
686
+ preview, truncated := githubOverviewPreview(body, 1200)
687
+ lines = append(lines, preview)
688
+ if truncated {
689
+ lines = append(lines, "", "> Description preview locally truncated for this overview.")
690
+ }
691
+ }
692
+ if bodySelector != "" {
693
+ lines = append(lines, "> Full description: "+bodySelector)
694
+ }
695
+
696
+ relLines, relOmitted := renderIssueRelationshipsOverview(rel)
697
+ if len(relLines) > 0 {
698
+ lines = append(lines, "", "## Relationships", "")
699
+ lines = append(lines, relLines...)
700
+ if note := githubLocalOmissionNote("relationship entries", relOmitted); note != "" {
701
+ lines = append(lines, "", note)
702
+ }
703
+ }
704
+
705
+ pinned := decodePinnedIssueComment(issue.PinnedComment)
706
+ pinnedInTimeline := false
707
+ if pinned != nil {
708
+ for _, event := range timeline {
709
+ if event.Event == "commented" && event.ID == pinned.ID {
710
+ pinnedInTimeline = true
711
+ break
712
+ }
713
+ }
714
+ }
715
+ if pinned != nil && !pinnedInTimeline {
716
+ lines = append(lines, "", "## Pinned comment", "")
717
+ lines = append(lines, renderIssueCommentIndex(target, *pinned)...)
718
+ }
719
+
720
+ lines = append(lines, "", "## Timeline index", "")
721
+ if len(visible) == 0 {
722
+ lines = append(lines, "_No substantive timeline activity._")
723
+ }
724
+ for _, event := range visible[:indexedLimit] {
725
+ if event.Event == "commented" {
726
+ comment := githubIssueComment{
727
+ ID: event.ID,
728
+ Body: event.Body,
729
+ HTMLURL: event.HTMLURL,
730
+ User: event.User,
731
+ AuthorAssociation: event.AuthorAssociation,
732
+ CreatedAt: event.CreatedAt,
733
+ UpdatedAt: event.UpdatedAt,
734
+ IsPinned: event.IsPinned || (pinned != nil && pinned.ID == event.ID),
735
+ Minimized: event.Minimized,
736
+ MinimizedReason: event.MinimizedReason,
737
+ }
738
+ lines = append(lines, renderIssueCommentIndex(target, comment)...)
739
+ continue
740
+ }
741
+ if rendered, ok := renderIssueTimelineState(event); ok {
742
+ lines = append(lines, rendered)
743
+ }
744
+ }
745
+ if note := githubLocalOmissionNote("substantive timeline items", len(visible)-indexedLimit); note != "" {
746
+ lines = append(lines, "", note)
747
+ }
748
+ if availability.TimelineProviderMore || issue.Comments > commentReturned {
749
+ lines = append(lines, "", fmt.Sprintf("> Provider-incomplete comment data: GitHub reports %d comments, while the bounded timeline page returned %d comment events. More timeline pages may exist upstream; this is separate from local overview omission.", issue.Comments, commentReturned))
750
+ }
751
+ if rel.providerMore() {
752
+ lines = append(lines, "", "> More Issue relationship entries exist upstream beyond the first provider pages fetched for this overview; local relationship omission is reported separately.")
753
+ }
754
+
755
+ base := fmt.Sprintf("https://github.com/%s/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo))
756
+ lines = append(lines, "", "## Useful GitHub URLs", "")
757
+ if bodySelector != "" {
758
+ lines = append(lines, "- Full Issue description: "+bodySelector)
759
+ }
760
+ lines = append(lines, "- Issue page: "+issuePageURL(target, issue), "- Issue list: "+base+"/issues")
761
+ return strings.TrimSpace(strings.Join(lines, "\n"))
762
+ }
763
+
764
+ func substantiveIssueTimeline(timeline []githubTimelineEvent) []githubTimelineEvent {
765
+ visible := make([]githubTimelineEvent, 0, len(timeline))
766
+ for _, event := range timeline {
767
+ if event.Event == "commented" {
768
+ visible = append(visible, event)
769
+ continue
770
+ }
771
+ if _, ok := renderIssueTimelineState(event); ok {
772
+ visible = append(visible, event)
773
+ }
774
+ }
775
+ return visible
776
+ }
777
+
778
+ func renderIssueRelationshipsOverview(rel githubIssueRelationships) ([]string, int) {
779
+ lines := []string{}
780
+ omitted := 0
781
+ if rel.Parent != nil {
782
+ lines = append(lines, "- Parent: "+issueRelationshipLink(*rel.Parent))
783
+ }
784
+ appendIssues := func(label string, issues []githubIssue) {
785
+ limit := len(issues)
786
+ if limit > 3 {
787
+ limit = 3
788
+ }
789
+ for _, issue := range issues[:limit] {
790
+ lines = append(lines, "- "+label+": "+issueRelationshipLink(issue))
791
+ }
792
+ omitted += len(issues) - limit
793
+ }
794
+ appendIssues("Sub-issue", rel.SubIssues)
795
+ appendIssues("Blocked by", rel.BlockedBy)
796
+ appendIssues("Blocking", rel.Blocking)
797
+ fieldLimit := len(rel.Fields)
798
+ if fieldLimit > 4 {
799
+ fieldLimit = 4
800
+ }
801
+ for _, field := range rel.Fields[:fieldLimit] {
802
+ if strings.TrimSpace(field.IssueFieldName) == "" {
803
+ continue
804
+ }
805
+ fieldName, fieldNameTruncated := githubOverviewInlinePreview(field.IssueFieldName, 80)
806
+ if fieldNameTruncated {
807
+ fieldName += "…"
808
+ }
809
+ fieldValue, fieldValueTruncated := githubOverviewInlinePreview(renderIssueFieldValue(field), 140)
810
+ if fieldValueTruncated {
811
+ fieldValue += "…"
812
+ }
813
+ lines = append(lines, "- "+fieldName+": "+fieldValue)
814
+ }
815
+ omitted += len(rel.Fields) - fieldLimit
816
+ return lines, omitted
817
+ }
818
+
819
+ func renderIssueCommentIndex(target *GitHubTarget, comment githubIssueComment) []string {
820
+ heading := fmt.Sprintf("### Comment `%d`", comment.ID)
821
+ if comment.User.Login != "" {
822
+ heading += " by @" + comment.User.Login
823
+ }
824
+ if comment.CreatedAt != "" {
825
+ heading += " — " + comment.CreatedAt
826
+ }
827
+ if comment.IsPinned {
828
+ heading += " · pinned"
829
+ }
830
+ body := strings.Join(renderIssueCommentBody(comment, comment.IsPinned), "\n")
831
+ preview, truncated := githubOverviewPreview(body, githubIndexPreviewRunes)
832
+ lines := []string{heading, ""}
833
+ for _, line := range strings.Split(preview, "\n") {
834
+ lines = append(lines, "> "+line)
835
+ }
836
+ if truncated {
837
+ lines = append(lines, "> _Preview locally truncated._")
838
+ }
839
+ lines = append(lines, "", "Selector: "+issueCommentSelectorURL(target, comment), "")
840
+ return lines
841
+ }
842
+
843
+ func issuePageURL(target *GitHubTarget, issue githubIssue) string {
844
+ if strings.TrimSpace(issue.HTMLURL) != "" {
845
+ return strings.TrimRight(issue.HTMLURL, "/")
846
+ }
847
+ return fmt.Sprintf("https://github.com/%s/%s/issues/%d", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), target.Number)
848
+ }
849
+
850
+ func issueBodySelectorURL(target *GitHubTarget, issue githubIssue) string {
851
+ if issue.ID <= 0 {
852
+ return ""
853
+ }
854
+ return issuePageURL(target, issue) + "#issue-" + strconv.FormatInt(issue.ID, 10)
855
+ }
856
+
857
+ func issueCommentSelectorURL(target *GitHubTarget, comment githubIssueComment) string {
858
+ if strings.TrimSpace(comment.HTMLURL) != "" {
859
+ return comment.HTMLURL
860
+ }
861
+ return issuePageURL(target, githubIssue{Number: target.Number}) + "#issuecomment-" + strconv.FormatInt(comment.ID, 10)
862
+ }
863
+
864
+ func renderGitHubIssueComment(target *GitHubTarget, comment githubIssueComment) string {
865
+ identityKey := "issue"
866
+ headingNoun := "Comment on"
867
+ if target.Kind == GitHubTargetPull {
868
+ identityKey = "pull_request"
869
+ headingNoun = "Comment on Pull Request"
870
+ }
871
+ lines := []string{
872
+ "---",
873
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
874
+ fmt.Sprintf("%s: %d", identityKey, target.Number),
875
+ fmt.Sprintf("comment_id: %d", comment.ID),
876
+ }
877
+ if comment.User.Login != "" {
878
+ lines = append(lines, "author: "+yamlScalar("@"+comment.User.Login))
879
+ }
880
+ if comment.AuthorAssociation != "" {
881
+ lines = append(lines, "association: "+yamlScalar(comment.AuthorAssociation))
882
+ }
883
+ if comment.CreatedAt != "" {
884
+ lines = append(lines, "created: "+yamlScalar(comment.CreatedAt))
885
+ }
886
+ if comment.UpdatedAt != "" && comment.UpdatedAt != comment.CreatedAt {
887
+ lines = append(lines, "updated: "+yamlScalar(comment.UpdatedAt))
888
+ }
889
+ if comment.IsPinned {
890
+ lines = append(lines, "pinned: true")
891
+ }
892
+ if comment.HTMLURL != "" {
893
+ lines = append(lines, "url: "+yamlScalar(comment.HTMLURL))
894
+ }
895
+ lines = append(lines, "---", "", fmt.Sprintf("# %s %s/%s#%d", headingNoun, target.Owner, target.Repo, target.Number), "")
896
+ lines = append(lines, renderIssueCommentBody(comment, false)...)
897
+ return strings.TrimSpace(strings.Join(lines, "\n"))
898
+ }
899
+
900
+ func renderTimelineComment(comment githubIssueComment) []string {
901
+ heading := "### Comment"
902
+ if comment.User.Login != "" {
903
+ heading += " by @" + comment.User.Login
904
+ }
905
+ if comment.CreatedAt != "" {
906
+ heading += " — " + comment.CreatedAt
907
+ }
908
+ if comment.IsPinned {
909
+ heading += " · pinned"
910
+ }
911
+ lines := []string{heading, ""}
912
+ lines = append(lines, renderIssueCommentBody(comment, comment.IsPinned)...)
913
+ lines = append(lines, "")
914
+ return lines
915
+ }
916
+
917
+ func renderIssueCommentBody(comment githubIssueComment, pinned bool) []string {
918
+ if comment.Minimized {
919
+ reason := strings.TrimSpace(comment.MinimizedReason)
920
+ if reason == "" {
921
+ reason = "reason not provided"
922
+ }
923
+ return []string{"_Comment minimized by GitHub (" + reason + ")._"}
924
+ }
925
+ if comment.Body == nil {
926
+ return []string{"_Comment body is unavailable or deleted._"}
927
+ }
928
+ body := strings.TrimSpace(stripInvisibleHTMLComments(*comment.Body))
929
+ if body == "" {
930
+ body = "_Comment is empty after removing invisible GitHub markup._"
931
+ }
932
+ return []string{body}
933
+ }
934
+
935
+ func renderIssueTimelineState(event githubTimelineEvent) (string, bool) {
936
+ name := strings.TrimSpace(event.Event)
937
+ if name == "" || name == "commented" {
938
+ return "", false
939
+ }
940
+ switch name {
941
+ case "mentioned", "subscribed", "unsubscribed", "head_ref_deleted", "head_ref_restored":
942
+ return "", false
943
+ }
944
+ actor := event.Actor.Login
945
+ if actor == "" {
946
+ actor = event.User.Login
947
+ }
948
+ prefix := "- "
949
+ if event.CreatedAt != "" {
950
+ prefix += event.CreatedAt + " — "
951
+ }
952
+ if actor != "" {
953
+ prefix += "@" + actor + " "
954
+ }
955
+ switch name {
956
+ case "closed":
957
+ return prefix + "closed the Issue.", true
958
+ case "reopened":
959
+ return prefix + "reopened the Issue.", true
960
+ case "labeled", "unlabeled":
961
+ label := ""
962
+ if event.Label != nil {
963
+ label = event.Label.Name
964
+ }
965
+ verb := "added"
966
+ if name == "unlabeled" {
967
+ verb = "removed"
968
+ }
969
+ return prefix + verb + " label `" + label + "`.", true
970
+ case "milestoned", "demilestoned":
971
+ title := ""
972
+ if event.Milestone != nil {
973
+ title = event.Milestone.Title
974
+ }
975
+ verb := "set milestone"
976
+ if name == "demilestoned" {
977
+ verb = "removed milestone"
978
+ }
979
+ return prefix + verb + " `" + title + "`.", true
980
+ case "assigned", "unassigned":
981
+ login := ""
982
+ if event.Assignee != nil {
983
+ login = event.Assignee.Login
984
+ }
985
+ verb := "assigned"
986
+ if name == "unassigned" {
987
+ verb = "unassigned"
988
+ }
989
+ if login != "" {
990
+ return prefix + verb + " @" + login + ".", true
991
+ }
992
+ return prefix + verb + " the Issue.", true
993
+ case "locked", "unlocked":
994
+ detail := name + " the conversation"
995
+ if name == "locked" && event.LockReason != "" {
996
+ detail += " (" + event.LockReason + ")"
997
+ }
998
+ return prefix + detail + ".", true
999
+ case "renamed":
1000
+ if event.Rename != nil {
1001
+ return prefix + "renamed the Issue from `" + event.Rename.From + "` to `" + event.Rename.To + "`.", true
1002
+ }
1003
+ return prefix + "renamed the Issue.", true
1004
+ case "cross-referenced":
1005
+ if event.Source != nil && event.Source.Issue != nil {
1006
+ source := event.Source.Issue
1007
+ label := fmt.Sprintf("#%d %s", source.Number, source.Title)
1008
+ if source.HTMLURL != "" {
1009
+ return prefix + "cross-referenced [" + escapeMarkdownLinkText(label) + "](" + source.HTMLURL + ").", true
1010
+ }
1011
+ return prefix + "cross-referenced " + label + ".", true
1012
+ }
1013
+ return prefix + "added a cross-reference.", true
1014
+ case "pinned":
1015
+ return prefix + "pinned a comment.", true
1016
+ case "unpinned":
1017
+ return prefix + "unpinned a comment.", true
1018
+ default:
1019
+ return prefix + strings.ReplaceAll(name, "_", " ") + ".", true
1020
+ }
1021
+ }
1022
+
1023
+ func renderIssueRelationships(rel githubIssueRelationships) []string {
1024
+ lines := []string{}
1025
+ if rel.Parent != nil {
1026
+ lines = append(lines, "- Parent: "+issueRelationshipLink(*rel.Parent))
1027
+ }
1028
+ if len(rel.SubIssues) > 0 {
1029
+ lines = append(lines, "- Sub-issues: "+joinIssueRelationshipLinks(rel.SubIssues))
1030
+ }
1031
+ if len(rel.BlockedBy) > 0 {
1032
+ lines = append(lines, "- Blocked by: "+joinIssueRelationshipLinks(rel.BlockedBy))
1033
+ }
1034
+ if len(rel.Blocking) > 0 {
1035
+ lines = append(lines, "- Blocking: "+joinIssueRelationshipLinks(rel.Blocking))
1036
+ }
1037
+ for _, field := range rel.Fields {
1038
+ if strings.TrimSpace(field.IssueFieldName) == "" {
1039
+ continue
1040
+ }
1041
+ lines = append(lines, "- "+field.IssueFieldName+": "+renderIssueFieldValue(field))
1042
+ }
1043
+ return lines
1044
+ }
1045
+
1046
+ func issueRelationshipLink(issue githubIssue) string {
1047
+ title, truncated := githubOverviewInlinePreview(issue.Title, 120)
1048
+ if truncated {
1049
+ title += "…"
1050
+ }
1051
+ label := fmt.Sprintf("#%d %s", issue.Number, title)
1052
+ if issue.HTMLURL != "" {
1053
+ return "[" + escapeMarkdownLinkText(label) + "](" + issue.HTMLURL + ")"
1054
+ }
1055
+ return label
1056
+ }
1057
+
1058
+ func joinIssueRelationshipLinks(issues []githubIssue) string {
1059
+ parts := make([]string, 0, len(issues))
1060
+ for _, issue := range issues {
1061
+ parts = append(parts, issueRelationshipLink(issue))
1062
+ }
1063
+ return strings.Join(parts, ", ")
1064
+ }
1065
+
1066
+ func renderIssueFieldValue(field githubIssueFieldValue) string {
1067
+ if field.SingleSelectOption != nil && field.SingleSelectOption.Name != "" {
1068
+ return field.SingleSelectOption.Name
1069
+ }
1070
+ if len(field.MultiSelectOptions) > 0 {
1071
+ names := make([]string, 0, len(field.MultiSelectOptions))
1072
+ for _, option := range field.MultiSelectOptions {
1073
+ if option.Name != "" {
1074
+ names = append(names, option.Name)
1075
+ }
1076
+ }
1077
+ if len(names) > 0 {
1078
+ return strings.Join(names, ", ")
1079
+ }
1080
+ }
1081
+ switch value := field.Value.(type) {
1082
+ case string:
1083
+ return value
1084
+ case float64:
1085
+ return strconv.FormatFloat(value, 'f', -1, 64)
1086
+ case nil:
1087
+ return "_empty_"
1088
+ default:
1089
+ encoded, _ := json.Marshal(value)
1090
+ return string(encoded)
1091
+ }
1092
+ }
1093
+
1094
+ func decodePinnedIssueComment(raw json.RawMessage) *githubIssueComment {
1095
+ trimmed := strings.TrimSpace(string(raw))
1096
+ if trimmed == "" || trimmed == "null" || trimmed == "{}" {
1097
+ return nil
1098
+ }
1099
+ var comment githubIssueComment
1100
+ if json.Unmarshal(raw, &comment) != nil || comment.ID == 0 {
1101
+ return nil
1102
+ }
1103
+ comment.IsPinned = true
1104
+ return &comment
1105
+ }
1106
+
1107
+ func githubIssueTypeName(raw json.RawMessage) string {
1108
+ trimmed := strings.TrimSpace(string(raw))
1109
+ if trimmed == "" || trimmed == "null" {
1110
+ return ""
1111
+ }
1112
+ var name string
1113
+ if json.Unmarshal(raw, &name) == nil {
1114
+ return name
1115
+ }
1116
+ var issueType githubIssueType
1117
+ if json.Unmarshal(raw, &issueType) == nil {
1118
+ return issueType.Name
1119
+ }
1120
+ return ""
1121
+ }
1122
+
1123
+ func issueLabelNames(labels []githubIssueLabel) []string {
1124
+ names := make([]string, 0, len(labels))
1125
+ for _, label := range labels {
1126
+ if strings.TrimSpace(label.Name) != "" {
1127
+ names = append(names, label.Name)
1128
+ }
1129
+ }
1130
+ sort.Strings(names)
1131
+ return names
1132
+ }
1133
+
1134
+ func githubUserLogins(users []githubUser) []string {
1135
+ logins := make([]string, 0, len(users))
1136
+ for _, user := range users {
1137
+ if strings.TrimSpace(user.Login) != "" {
1138
+ logins = append(logins, "@"+user.Login)
1139
+ }
1140
+ }
1141
+ sort.Strings(logins)
1142
+ return logins
1143
+ }
1144
+
1145
+ func readGitHubIssueList(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
1146
+ if target.Fragment != "" {
1147
+ return "", fmt.Errorf("GitHub Issue-list fragment %q is not a supported native selector", target.Fragment)
1148
+ }
1149
+ if strings.TrimSpace(target.Query.Get("q")) != "" {
1150
+ resource, err := githubSearchResourceQualifier(target.Query.Get("q"))
1151
+ if err != nil {
1152
+ return "", err
1153
+ }
1154
+ if resource == "pull_request" {
1155
+ return readGitHubPullSearch(ctx, client, target)
1156
+ }
1157
+ return readGitHubIssueSearch(ctx, client, target)
1158
+ }
1159
+ query := copySelectedQuery(target.Query, []string{"milestone", "state", "assignee", "type", "creator", "mentioned", "labels", "sort", "direction", "since", "page"})
1160
+ query.Set("per_page", strconv.Itoa(githubPageableListSize))
1161
+ endpoint := fmt.Sprintf("/repos/%s/%s/issues?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
1162
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
1163
+ if err != nil {
1164
+ return "", err
1165
+ }
1166
+ var issues []githubIssue
1167
+ if err := json.Unmarshal(resp.Body, &issues); err != nil {
1168
+ return "", fmt.Errorf("decode GitHub Issue list: %w", err)
1169
+ }
1170
+ issues = filterPullRequestsFromIssues(issues)
1171
+ return renderGitHubIssueList(target, issues, resp.Links(), -1, false), nil
1172
+ }
1173
+
1174
+ func readGitHubIssueSearch(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
1175
+ q := strings.TrimSpace(target.Query.Get("q"))
1176
+ resource, err := githubSearchResourceQualifier(q)
1177
+ if err != nil {
1178
+ return "", err
1179
+ }
1180
+ if resource == "pull_request" {
1181
+ return readGitHubPullSearch(ctx, client, target)
1182
+ }
1183
+ if !hasGitHubSearchQualifierPrefix(q, "repo:") {
1184
+ q = "repo:" + target.Owner + "/" + target.Repo + " " + q
1185
+ }
1186
+ if resource == "" {
1187
+ q += " is:issue"
1188
+ }
1189
+ apiQuery := url.Values{"q": []string{strings.TrimSpace(q)}}
1190
+ for _, key := range []string{"sort", "order", "page"} {
1191
+ if value := target.Query.Get(key); value != "" {
1192
+ apiQuery.Set(key, value)
1193
+ }
1194
+ }
1195
+ apiQuery.Set("per_page", strconv.Itoa(githubPageableListSize))
1196
+ endpoint := "/search/issues?" + apiQuery.Encode()
1197
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
1198
+ if err != nil {
1199
+ return "", err
1200
+ }
1201
+ var result githubIssueSearchResponse
1202
+ if err := json.Unmarshal(resp.Body, &result); err != nil {
1203
+ return "", fmt.Errorf("decode GitHub Issue search: %w", err)
1204
+ }
1205
+ result.Items = filterPullRequestsFromIssues(result.Items)
1206
+ return renderGitHubIssueList(target, result.Items, resp.Links(), result.TotalCount, result.IncompleteResults), nil
1207
+ }
1208
+
1209
+ func githubSearchResourceQualifier(query string) (string, error) {
1210
+ hasIssue := false
1211
+ hasPR := false
1212
+ for _, token := range githubSearchTokens(query) {
1213
+ switch strings.ToLower(token) {
1214
+ case "is:issue":
1215
+ hasIssue = true
1216
+ case "is:pr":
1217
+ hasPR = true
1218
+ }
1219
+ }
1220
+ if hasIssue && hasPR {
1221
+ return "", fmt.Errorf("GitHub search query contains conflicting explicit is:issue and is:pr qualifiers")
1222
+ }
1223
+ if hasPR {
1224
+ return "pull_request", nil
1225
+ }
1226
+ if hasIssue {
1227
+ return "issue", nil
1228
+ }
1229
+ return "", nil
1230
+ }
1231
+
1232
+ func hasGitHubSearchQualifierPrefix(query, prefix string) bool {
1233
+ prefix = strings.ToLower(prefix)
1234
+ for _, token := range githubSearchTokens(query) {
1235
+ if strings.HasPrefix(strings.ToLower(token), prefix) {
1236
+ return true
1237
+ }
1238
+ }
1239
+ return false
1240
+ }
1241
+
1242
+ func githubSearchTokens(query string) []string {
1243
+ tokens := []string{}
1244
+ var current strings.Builder
1245
+ inQuote := false
1246
+ escaped := false
1247
+ flush := func() {
1248
+ if current.Len() == 0 {
1249
+ return
1250
+ }
1251
+ tokens = append(tokens, current.String())
1252
+ current.Reset()
1253
+ }
1254
+ for _, r := range query {
1255
+ if escaped {
1256
+ current.WriteRune(r)
1257
+ escaped = false
1258
+ continue
1259
+ }
1260
+ if inQuote && r == '\\' {
1261
+ current.WriteRune(r)
1262
+ escaped = true
1263
+ continue
1264
+ }
1265
+ if r == '"' {
1266
+ inQuote = !inQuote
1267
+ current.WriteRune(r)
1268
+ continue
1269
+ }
1270
+ if !inQuote && (r == ' ' || r == '\t' || r == '\n' || r == '\r') {
1271
+ flush()
1272
+ continue
1273
+ }
1274
+ current.WriteRune(r)
1275
+ }
1276
+ flush()
1277
+ return tokens
1278
+ }
1279
+
1280
+ func filterPullRequestsFromIssues(issues []githubIssue) []githubIssue {
1281
+ out := make([]githubIssue, 0, len(issues))
1282
+ for _, issue := range issues {
1283
+ if issue.PullRequest == nil {
1284
+ out = append(out, issue)
1285
+ }
1286
+ }
1287
+ return out
1288
+ }
1289
+
1290
+ func renderGitHubIssueList(target *GitHubTarget, issues []githubIssue, links GitHubLinkRelations, total int, incomplete bool) string {
1291
+ return renderGitHubIssueListWithLimit(target, issues, links, total, incomplete, len(issues))
1292
+ }
1293
+
1294
+ func renderGitHubIssueListWithLimit(target *GitHubTarget, issues []githubIssue, links GitHubLinkRelations, total int, incomplete bool, limit int) string {
1295
+ limit = minInt(limit, len(issues))
1296
+ lines := []string{
1297
+ "---",
1298
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
1299
+ "view: issues",
1300
+ fmt.Sprintf("results: %d", len(issues)),
1301
+ fmt.Sprintf("results_indexed: %d", limit),
1302
+ fmt.Sprintf("results_local_omitted: %d", len(issues)-limit),
1303
+ }
1304
+ if q := target.Query.Get("q"); q != "" {
1305
+ preview, truncated := githubOverviewInlinePreview(q, 300)
1306
+ lines = append(lines, "query: "+yamlScalar(preview))
1307
+ if truncated {
1308
+ lines = append(lines, "query_preview_truncated: true")
1309
+ }
1310
+ }
1311
+ if total >= 0 {
1312
+ lines = append(lines, fmt.Sprintf("total_matches: %d", total))
1313
+ }
1314
+ if incomplete {
1315
+ lines = append(lines, "complete: false")
1316
+ }
1317
+ if page := target.Query.Get("page"); page != "" {
1318
+ lines = append(lines, "page: "+yamlScalar(page))
1319
+ }
1320
+ lines = append(lines, "---", "", "# Issues", "")
1321
+ if incomplete {
1322
+ lines = append(lines, "> GitHub marked this search result set as incomplete.", "")
1323
+ }
1324
+ if len(issues) == 0 {
1325
+ lines = append(lines, "_No Issues on this page._")
1326
+ }
1327
+ for _, issue := range issues[:limit] {
1328
+ urlText := issue.HTMLURL
1329
+ if urlText == "" {
1330
+ urlText = fmt.Sprintf("https://github.com/%s/%s/issues/%d", target.Owner, target.Repo, issue.Number)
1331
+ }
1332
+ meta := []string{issue.State}
1333
+ if issue.User.Login != "" {
1334
+ meta = append(meta, "@"+issue.User.Login)
1335
+ }
1336
+ if issue.UpdatedAt != "" {
1337
+ meta = append(meta, "updated "+issue.UpdatedAt)
1338
+ }
1339
+ if labels := issueLabelNames(issue.Labels); len(labels) > 0 {
1340
+ meta = append(meta, "labels: "+githubOverviewLabelList(labels, 3))
1341
+ }
1342
+ title, truncated := githubOverviewInlinePreview(issue.Title, 140)
1343
+ if truncated {
1344
+ title += "…"
1345
+ }
1346
+ lines = append(lines, fmt.Sprintf("- [#%d %s](%s) — %s", issue.Number, escapeMarkdownLinkText(title), urlText, strings.Join(meta, " · ")))
1347
+ }
1348
+ if nav := renderGitHubUIPageNavigation(target, links); len(nav) > 0 {
1349
+ lines = append(lines, "", "## Navigation", "")
1350
+ lines = append(lines, nav...)
1351
+ }
1352
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1353
+ }
1354
+
1355
+ func readGitHubLabelList(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
1356
+ if target.Fragment != "" {
1357
+ return "", fmt.Errorf("GitHub label-list fragment %q is not supported", target.Fragment)
1358
+ }
1359
+ query := copySelectedQuery(target.Query, []string{"page"})
1360
+ query.Set("per_page", strconv.Itoa(githubPageableListSize))
1361
+ endpoint := fmt.Sprintf("/repos/%s/%s/labels?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
1362
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
1363
+ if err != nil {
1364
+ return "", err
1365
+ }
1366
+ var labels []githubIssueLabel
1367
+ if err := json.Unmarshal(resp.Body, &labels); err != nil {
1368
+ return "", fmt.Errorf("decode GitHub labels: %w", err)
1369
+ }
1370
+ out := func(limit int) string {
1371
+ lines := []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), "view: labels", fmt.Sprintf("results: %d", len(labels)), fmt.Sprintf("results_indexed: %d", limit), fmt.Sprintf("results_local_omitted: %d", len(labels)-limit), "---", "", "# Labels", ""}
1372
+ for _, label := range labels[:limit] {
1373
+ labelURL := fmt.Sprintf("https://github.com/%s/%s/labels/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), url.PathEscape(label.Name))
1374
+ name, truncated := githubOverviewInlinePreview(label.Name, 100)
1375
+ if truncated {
1376
+ name += "…"
1377
+ }
1378
+ line := "- [" + escapeMarkdownLinkText(name) + "](" + labelURL + ")"
1379
+ if label.Description != "" {
1380
+ description, descriptionTruncated := githubOverviewInlinePreview(label.Description, 140)
1381
+ if descriptionTruncated {
1382
+ description += "…"
1383
+ }
1384
+ line += " — " + description
1385
+ }
1386
+ lines = append(lines, line)
1387
+ }
1388
+ if len(labels) == 0 {
1389
+ lines = append(lines, "_No labels on this page._")
1390
+ }
1391
+ if nav := renderGitHubUIPageNavigation(target, resp.Links()); len(nav) > 0 {
1392
+ lines = append(lines, "", "## Navigation", "")
1393
+ lines = append(lines, nav...)
1394
+ }
1395
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1396
+ }(len(labels))
1397
+ return out, nil
1398
+ }
1399
+
1400
+ func readGitHubLabel(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
1401
+ if target.Fragment != "" {
1402
+ return "", fmt.Errorf("GitHub label fragment %q is not supported", target.Fragment)
1403
+ }
1404
+ endpoint := fmt.Sprintf("/repos/%s/%s/labels/%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), url.PathEscape(target.Name))
1405
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
1406
+ if err != nil {
1407
+ return "", err
1408
+ }
1409
+ var label githubIssueLabel
1410
+ if err := json.Unmarshal(resp.Body, &label); err != nil {
1411
+ return "", fmt.Errorf("decode GitHub label: %w", err)
1412
+ }
1413
+ listQuery := url.Values{"labels": []string{target.Name}, "state": []string{"all"}, "per_page": []string{strconv.Itoa(githubPageableListSize)}}
1414
+ if page := target.Query.Get("page"); page != "" {
1415
+ listQuery.Set("page", page)
1416
+ }
1417
+ listEndpoint := fmt.Sprintf("/repos/%s/%s/issues?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), listQuery.Encode())
1418
+ listResp, err := client.REST(ctx, http.MethodGet, listEndpoint, "application/vnd.github+json")
1419
+ if err != nil {
1420
+ return "", err
1421
+ }
1422
+ var issues []githubIssue
1423
+ if err := json.Unmarshal(listResp.Body, &issues); err != nil {
1424
+ return "", fmt.Errorf("decode GitHub label Issues: %w", err)
1425
+ }
1426
+ issues = filterPullRequestsFromIssues(issues)
1427
+ lines := []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), "label: " + yamlScalar(label.Name)}
1428
+ if label.Color != "" {
1429
+ lines = append(lines, "color: "+yamlScalar("#"+label.Color))
1430
+ }
1431
+ if label.Description != "" {
1432
+ description, truncated := githubOverviewInlinePreview(label.Description, 500)
1433
+ lines = append(lines, "description: "+yamlScalar(description))
1434
+ if truncated {
1435
+ lines = append(lines, "description_preview_truncated: true")
1436
+ }
1437
+ }
1438
+ lines = append(lines, fmt.Sprintf("issues_on_page: %d", len(issues)), "---", "", "# Label: "+label.Name, "")
1439
+ lines = append(lines, renderIssueLinksCompact(issues)...)
1440
+ filteredURL := fmt.Sprintf("https://github.com/%s/%s/issues?q=%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), url.QueryEscape(`is:issue label:"`+label.Name+`"`))
1441
+ lines = append(lines, "", "## Useful GitHub URLs", "", "- Filtered Issues: "+filteredURL)
1442
+ return strings.TrimSpace(strings.Join(lines, "\n")), nil
1443
+ }
1444
+
1445
+ func readGitHubMilestones(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
1446
+ if target.Fragment != "" {
1447
+ return "", fmt.Errorf("GitHub milestone-list fragment %q is not supported", target.Fragment)
1448
+ }
1449
+ query := copySelectedQuery(target.Query, []string{"state", "sort", "direction", "page"})
1450
+ query.Set("per_page", strconv.Itoa(githubPageableListSize))
1451
+ endpoint := fmt.Sprintf("/repos/%s/%s/milestones?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
1452
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
1453
+ if err != nil {
1454
+ return "", err
1455
+ }
1456
+ var milestones []githubIssueMilestone
1457
+ if err := json.Unmarshal(resp.Body, &milestones); err != nil {
1458
+ return "", fmt.Errorf("decode GitHub milestones: %w", err)
1459
+ }
1460
+ out := func(limit int) string {
1461
+ lines := []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), "view: milestones", fmt.Sprintf("results: %d", len(milestones)), fmt.Sprintf("results_indexed: %d", limit), fmt.Sprintf("results_local_omitted: %d", len(milestones)-limit), "---", "", "# Milestones", ""}
1462
+ if len(milestones) == 0 {
1463
+ lines = append(lines, "_No milestones on this page._")
1464
+ }
1465
+ for _, milestone := range milestones[:limit] {
1466
+ href := fmt.Sprintf("https://github.com/%s/%s/milestone/%d", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), milestone.Number)
1467
+ meta := fmt.Sprintf("%s · %d open / %d closed", milestone.State, milestone.OpenIssues, milestone.ClosedIssues)
1468
+ if milestone.DueOn != "" {
1469
+ meta += " · due " + milestone.DueOn
1470
+ }
1471
+ title, truncated := githubOverviewInlinePreview(milestone.Title, 140)
1472
+ if truncated {
1473
+ title += "…"
1474
+ }
1475
+ lines = append(lines, fmt.Sprintf("- [%s](%s) — %s", escapeMarkdownLinkText(title), href, meta))
1476
+ }
1477
+ if nav := renderGitHubUIPageNavigation(target, resp.Links()); len(nav) > 0 {
1478
+ lines = append(lines, "", "## Navigation", "")
1479
+ lines = append(lines, nav...)
1480
+ }
1481
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1482
+ }(len(milestones))
1483
+ return out, nil
1484
+ }
1485
+
1486
+ func readGitHubMilestone(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
1487
+ if target.Fragment != "" {
1488
+ return "", fmt.Errorf("GitHub milestone fragment %q is not supported", target.Fragment)
1489
+ }
1490
+ endpoint := fmt.Sprintf("/repos/%s/%s/milestones/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
1491
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
1492
+ if err != nil {
1493
+ return "", err
1494
+ }
1495
+ var milestone githubIssueMilestone
1496
+ if err := json.Unmarshal(resp.Body, &milestone); err != nil {
1497
+ return "", fmt.Errorf("decode GitHub milestone: %w", err)
1498
+ }
1499
+ listQuery := url.Values{"milestone": []string{strconv.Itoa(target.Number)}, "state": []string{"all"}, "per_page": []string{strconv.Itoa(githubPageableListSize)}}
1500
+ if page := target.Query.Get("page"); page != "" {
1501
+ listQuery.Set("page", page)
1502
+ }
1503
+ listEndpoint := fmt.Sprintf("/repos/%s/%s/issues?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), listQuery.Encode())
1504
+ listResp, err := client.REST(ctx, http.MethodGet, listEndpoint, "application/vnd.github+json")
1505
+ if err != nil {
1506
+ return "", err
1507
+ }
1508
+ var issues []githubIssue
1509
+ if err := json.Unmarshal(listResp.Body, &issues); err != nil {
1510
+ return "", fmt.Errorf("decode GitHub milestone Issues: %w", err)
1511
+ }
1512
+ issues = filterPullRequestsFromIssues(issues)
1513
+ titlePreview, titleTruncated := githubOverviewInlinePreview(milestone.Title, 180)
1514
+ if titleTruncated {
1515
+ titlePreview += "…"
1516
+ }
1517
+ lines := []string{
1518
+ "---",
1519
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
1520
+ fmt.Sprintf("milestone: %d", milestone.Number),
1521
+ "title: " + yamlScalar(titlePreview),
1522
+ "state: " + yamlScalar(milestone.State),
1523
+ fmt.Sprintf("open_issues: %d", milestone.OpenIssues),
1524
+ fmt.Sprintf("closed_issues: %d", milestone.ClosedIssues),
1525
+ }
1526
+ if milestone.DueOn != "" {
1527
+ lines = append(lines, "due: "+yamlScalar(milestone.DueOn))
1528
+ }
1529
+ lines = append(lines, "---", "", "# Milestone: "+titlePreview, "")
1530
+ if strings.TrimSpace(milestone.Description) != "" {
1531
+ preview, truncated := githubOverviewPreview(stripInvisibleHTMLComments(milestone.Description), 1000)
1532
+ lines = append(lines, preview, "")
1533
+ if truncated {
1534
+ href := fmt.Sprintf("https://github.com/%s/%s/milestone/%d", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), milestone.Number)
1535
+ lines = append(lines, "> Milestone description preview locally truncated. Full milestone: "+href, "")
1536
+ }
1537
+ }
1538
+ lines = append(lines, "## Issues", "")
1539
+ lines = append(lines, renderIssueLinksCompact(issues)...)
1540
+ return strings.TrimSpace(strings.Join(lines, "\n")), nil
1541
+ }
1542
+
1543
+ func renderIssueLinksCompact(issues []githubIssue) []string {
1544
+ if len(issues) == 0 {
1545
+ return []string{"_No Issues on this page._"}
1546
+ }
1547
+ limit := minInt(20, len(issues))
1548
+ lines := make([]string, 0, limit+2)
1549
+ for _, issue := range issues[:limit] {
1550
+ href := issue.HTMLURL
1551
+ if href == "" {
1552
+ href = "#"
1553
+ }
1554
+ title, truncated := githubOverviewInlinePreview(issue.Title, 140)
1555
+ if truncated {
1556
+ title += "…"
1557
+ }
1558
+ lines = append(lines, fmt.Sprintf("- [#%d %s](%s) — %s", issue.Number, escapeMarkdownLinkText(title), href, issue.State))
1559
+ }
1560
+ if note := githubLocalOmissionNote("Issues returned on this provider page", len(issues)-limit); note != "" {
1561
+ lines = append(lines, "", note)
1562
+ }
1563
+ return lines
1564
+ }
1565
+
1566
+ func copySelectedQuery(source url.Values, keys []string) url.Values {
1567
+ out := url.Values{}
1568
+ for _, key := range keys {
1569
+ for _, value := range source[key] {
1570
+ out.Add(key, value)
1571
+ }
1572
+ }
1573
+ return out
1574
+ }
1575
+
1576
+ func renderGitHubUIPageNavigation(target *GitHubTarget, links GitHubLinkRelations) []string {
1577
+ lines := []string{}
1578
+ if page, ok := pageFromGitHubLink(links["prev"]); ok {
1579
+ lines = append(lines, "- Previous: "+githubTargetPageURL(target, page))
1580
+ }
1581
+ if page, ok := pageFromGitHubLink(links["next"]); ok {
1582
+ lines = append(lines, "- Next: "+githubTargetPageURL(target, page))
1583
+ }
1584
+ return lines
1585
+ }
1586
+
1587
+ func pageFromGitHubLink(raw string) (int, bool) {
1588
+ if strings.TrimSpace(raw) == "" {
1589
+ return 0, false
1590
+ }
1591
+ parsed, err := url.Parse(raw)
1592
+ if err != nil {
1593
+ return 0, false
1594
+ }
1595
+ page, err := strconv.Atoi(parsed.Query().Get("page"))
1596
+ if err != nil || page <= 0 {
1597
+ return 0, false
1598
+ }
1599
+ return page, true
1600
+ }
1601
+
1602
+ func githubTargetPageURL(target *GitHubTarget, page int) string {
1603
+ parsed, err := url.Parse(target.OriginalURL)
1604
+ if err != nil {
1605
+ return target.OriginalURL
1606
+ }
1607
+ query := parsed.Query()
1608
+ query.Set("page", strconv.Itoa(page))
1609
+ parsed.RawQuery = query.Encode()
1610
+ return parsed.String()
1611
+ }
1612
+
1613
+ func parseGitHubTime(raw string) time.Time {
1614
+ t, _ := time.Parse(time.RFC3339, raw)
1615
+ return t
1616
+ }