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,1459 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "net/url"
9
+ "sort"
10
+ "strconv"
11
+ "strings"
12
+ )
13
+
14
+ type githubPullRef struct {
15
+ Label string `json:"label"`
16
+ Ref string `json:"ref"`
17
+ SHA string `json:"sha"`
18
+ }
19
+
20
+ type githubPullRequest struct {
21
+ ID int64 `json:"id"`
22
+ Number int `json:"number"`
23
+ State string `json:"state"`
24
+ Title string `json:"title"`
25
+ Body *string `json:"body"`
26
+ HTMLURL string `json:"html_url"`
27
+ User githubUser `json:"user"`
28
+ AuthorAssociation string `json:"author_association"`
29
+ Draft bool `json:"draft"`
30
+ Merged bool `json:"merged"`
31
+ MergeableState string `json:"mergeable_state"`
32
+ Locked bool `json:"locked"`
33
+ CreatedAt string `json:"created_at"`
34
+ UpdatedAt string `json:"updated_at"`
35
+ ClosedAt string `json:"closed_at"`
36
+ MergedAt string `json:"merged_at"`
37
+ MergeCommitSHA string `json:"merge_commit_sha"`
38
+ Comments int `json:"comments"`
39
+ ReviewComments int `json:"review_comments"`
40
+ Commits int `json:"commits"`
41
+ Additions int `json:"additions"`
42
+ Deletions int `json:"deletions"`
43
+ ChangedFiles int `json:"changed_files"`
44
+ Head githubPullRef `json:"head"`
45
+ Base githubPullRef `json:"base"`
46
+ }
47
+
48
+ type githubPullReview struct {
49
+ ID int64 `json:"id"`
50
+ User githubUser `json:"user"`
51
+ Body *string `json:"body"`
52
+ State string `json:"state"`
53
+ HTMLURL string `json:"html_url"`
54
+ CommitID string `json:"commit_id"`
55
+ SubmittedAt string `json:"submitted_at"`
56
+ AuthorAssociation string `json:"author_association"`
57
+ }
58
+
59
+ type githubPullReviewComment struct {
60
+ ID int64 `json:"id"`
61
+ PullRequestReviewID int64 `json:"pull_request_review_id"`
62
+ InReplyToID *int64 `json:"in_reply_to_id"`
63
+ User githubUser `json:"user"`
64
+ Body *string `json:"body"`
65
+ HTMLURL string `json:"html_url"`
66
+ PullRequestURL string `json:"pull_request_url"`
67
+ Path string `json:"path"`
68
+ DiffHunk string `json:"diff_hunk"`
69
+ CommitID string `json:"commit_id"`
70
+ OriginalCommitID string `json:"original_commit_id"`
71
+ Line *int `json:"line"`
72
+ OriginalLine *int `json:"original_line"`
73
+ StartLine *int `json:"start_line"`
74
+ OriginalStartLine *int `json:"original_start_line"`
75
+ Side string `json:"side"`
76
+ OriginalSide string `json:"original_side"`
77
+ StartSide string `json:"start_side"`
78
+ CreatedAt string `json:"created_at"`
79
+ UpdatedAt string `json:"updated_at"`
80
+ AuthorAssociation string `json:"author_association"`
81
+ }
82
+
83
+ type githubPullThreadState struct {
84
+ Resolved bool
85
+ Outdated bool
86
+ ResolvedBy string
87
+ }
88
+
89
+ type githubPullThread struct {
90
+ Root githubPullReviewComment
91
+ Replies []githubPullReviewComment
92
+ State *githubPullThreadState
93
+ }
94
+
95
+ type githubPullOverviewAvailability struct {
96
+ TimelineProviderMore bool
97
+ ReviewsProviderMore bool
98
+ ReviewCommentsProviderMore bool
99
+ ReviewCommentsReturned int
100
+ ThreadStatesProviderMore bool
101
+ }
102
+
103
+ type githubPullListItem struct {
104
+ Number int
105
+ State string
106
+ Title string
107
+ HTMLURL string
108
+ User githubUser
109
+ UpdatedAt string
110
+ CreatedAt string
111
+ Labels []githubIssueLabel
112
+ }
113
+
114
+ func readGitHubPullList(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
115
+ if target.Fragment != "" {
116
+ return "", fmt.Errorf("GitHub Pull Request-list fragment %q is not a supported native selector", target.Fragment)
117
+ }
118
+ if strings.TrimSpace(target.Query.Get("q")) != "" {
119
+ return readGitHubPullSearch(ctx, client, target)
120
+ }
121
+ query, err := githubPullListProviderQuery(target.Query)
122
+ if err != nil {
123
+ return "", err
124
+ }
125
+ endpoint := fmt.Sprintf("/repos/%s/%s/pulls?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
126
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
127
+ if err != nil {
128
+ return "", err
129
+ }
130
+ var pulls []githubPullRequest
131
+ if err := json.Unmarshal(resp.Body, &pulls); err != nil {
132
+ return "", fmt.Errorf("decode GitHub Pull Request list: %w", err)
133
+ }
134
+ items := make([]githubPullListItem, 0, len(pulls))
135
+ for _, pr := range pulls {
136
+ items = append(items, githubPullListItem{
137
+ Number: pr.Number,
138
+ State: pullDisplayState(pr),
139
+ Title: pr.Title,
140
+ HTMLURL: pr.HTMLURL,
141
+ User: pr.User,
142
+ UpdatedAt: pr.UpdatedAt,
143
+ CreatedAt: pr.CreatedAt,
144
+ })
145
+ }
146
+ return renderGitHubPullList(target, items, resp.Links(), -1, false), nil
147
+ }
148
+
149
+ func readGitHubPullSearch(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
150
+ provider, err := githubPullSearchProviderQuery(target.Query, target.Owner, target.Repo)
151
+ if err != nil {
152
+ return "", err
153
+ }
154
+ resp, err := client.REST(ctx, http.MethodGet, "/search/issues?"+provider.Encode(), "application/vnd.github+json")
155
+ if err != nil {
156
+ return "", err
157
+ }
158
+ var result githubIssueSearchResponse
159
+ if err := json.Unmarshal(resp.Body, &result); err != nil {
160
+ return "", fmt.Errorf("decode GitHub Pull Request search: %w", err)
161
+ }
162
+ items := make([]githubPullListItem, 0, len(result.Items))
163
+ for _, item := range result.Items {
164
+ state := item.State
165
+ if item.Draft && item.State == "open" {
166
+ state = "draft"
167
+ }
168
+ href := item.HTMLURL
169
+ if item.PullRequest != nil && item.PullRequest.HTMLURL != "" {
170
+ href = item.PullRequest.HTMLURL
171
+ }
172
+ items = append(items, githubPullListItem{
173
+ Number: item.Number,
174
+ State: state,
175
+ Title: item.Title,
176
+ HTMLURL: href,
177
+ User: item.User,
178
+ UpdatedAt: item.UpdatedAt,
179
+ CreatedAt: item.CreatedAt,
180
+ Labels: item.Labels,
181
+ })
182
+ }
183
+ return renderGitHubPullList(target, items, resp.Links(), result.TotalCount, result.IncompleteResults), nil
184
+ }
185
+
186
+ func githubPullListProviderQuery(input url.Values) (url.Values, error) {
187
+ allowed := map[string]struct{}{
188
+ "state": {}, "head": {}, "base": {}, "sort": {}, "direction": {}, "page": {},
189
+ }
190
+ for key := range input {
191
+ if _, ok := allowed[key]; !ok {
192
+ return nil, fmt.Errorf("GitHub Pull Request list query parameter %q is not supported by this native view", key)
193
+ }
194
+ }
195
+ query := copySelectedQuery(input, []string{"state", "head", "base", "sort", "direction", "page"})
196
+ if state := query.Get("state"); state != "" && state != "open" && state != "closed" && state != "all" {
197
+ return nil, fmt.Errorf("invalid GitHub Pull Request list state %q", state)
198
+ }
199
+ if sortBy := query.Get("sort"); sortBy != "" && sortBy != "created" && sortBy != "updated" && sortBy != "popularity" && sortBy != "long-running" {
200
+ return nil, fmt.Errorf("invalid GitHub Pull Request list sort %q", sortBy)
201
+ }
202
+ if direction := query.Get("direction"); direction != "" && direction != "asc" && direction != "desc" {
203
+ return nil, fmt.Errorf("invalid GitHub Pull Request list direction %q", direction)
204
+ }
205
+ if err := validateGitHubPageSize(query); err != nil {
206
+ return nil, err
207
+ }
208
+ query.Set("per_page", strconv.Itoa(githubPageableListSize))
209
+ return query, nil
210
+ }
211
+
212
+ func githubPullSearchProviderQuery(input url.Values, owner, repo string) (url.Values, error) {
213
+ allowed := map[string]struct{}{"q": {}, "sort": {}, "order": {}, "page": {}}
214
+ for key := range input {
215
+ if _, ok := allowed[key]; !ok {
216
+ return nil, fmt.Errorf("GitHub Pull Request search query parameter %q is not supported by this native view", key)
217
+ }
218
+ }
219
+ q := strings.TrimSpace(input.Get("q"))
220
+ if q == "" {
221
+ return nil, fmt.Errorf("GitHub Pull Request search requires a non-empty q parameter")
222
+ }
223
+ resource, err := githubSearchResourceQualifier(q)
224
+ if err != nil {
225
+ return nil, err
226
+ }
227
+ if resource == "issue" {
228
+ return nil, fmt.Errorf("GitHub Pull Request search query cannot explicitly select is:issue")
229
+ }
230
+ if resource == "" {
231
+ q += " is:pr"
232
+ }
233
+ if !hasGitHubSearchQualifierPrefix(q, "repo:") {
234
+ q = "repo:" + owner + "/" + repo + " " + q
235
+ }
236
+ provider := url.Values{"q": []string{strings.TrimSpace(q)}}
237
+ for _, key := range []string{"sort", "order", "page"} {
238
+ if value := input.Get(key); value != "" {
239
+ provider.Set(key, value)
240
+ }
241
+ }
242
+ if order := provider.Get("order"); order != "" && order != "asc" && order != "desc" {
243
+ return nil, fmt.Errorf("invalid GitHub Pull Request search order %q", order)
244
+ }
245
+ if err := validateGitHubPageSize(provider); err != nil {
246
+ return nil, err
247
+ }
248
+ provider.Set("per_page", strconv.Itoa(githubPageableListSize))
249
+ return provider, nil
250
+ }
251
+
252
+ func validateGitHubPageSize(query url.Values) error {
253
+ if rawPage := query.Get("page"); rawPage != "" {
254
+ page, err := strconv.Atoi(rawPage)
255
+ if err != nil || page <= 0 {
256
+ return fmt.Errorf("invalid GitHub list page %q", rawPage)
257
+ }
258
+ }
259
+ if rawSize := query.Get("per_page"); rawSize != "" {
260
+ size, err := strconv.Atoi(rawSize)
261
+ if err != nil || size <= 0 || size > 100 {
262
+ return fmt.Errorf("invalid GitHub list per_page %q", rawSize)
263
+ }
264
+ }
265
+ return nil
266
+ }
267
+
268
+ func renderGitHubPullList(target *GitHubTarget, pulls []githubPullListItem, links GitHubLinkRelations, total int, incomplete bool) string {
269
+ return renderGitHubPullListWithLimit(target, pulls, links, total, incomplete, len(pulls))
270
+ }
271
+
272
+ func renderGitHubPullListWithLimit(target *GitHubTarget, pulls []githubPullListItem, links GitHubLinkRelations, total int, incomplete bool, limit int) string {
273
+ limit = minInt(limit, len(pulls))
274
+ page := target.Query.Get("page")
275
+ if page == "" {
276
+ page = "1"
277
+ }
278
+ lines := []string{
279
+ "---",
280
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
281
+ "view: pull_requests",
282
+ "page: " + yamlScalar(page),
283
+ fmt.Sprintf("results: %d", len(pulls)),
284
+ fmt.Sprintf("results_indexed: %d", limit),
285
+ fmt.Sprintf("results_local_omitted: %d", len(pulls)-limit),
286
+ }
287
+ if q := target.Query.Get("q"); q != "" {
288
+ preview, truncated := githubOverviewInlinePreview(q, 300)
289
+ lines = append(lines, "query: "+yamlScalar(preview))
290
+ if truncated {
291
+ lines = append(lines, "query_preview_truncated: true")
292
+ }
293
+ }
294
+ if total >= 0 {
295
+ lines = append(lines, fmt.Sprintf("total_matches: %d", total))
296
+ if total > 1000 {
297
+ lines = append(lines, "provider_result_ceiling: 1000")
298
+ }
299
+ }
300
+ if incomplete {
301
+ lines = append(lines, "complete: false")
302
+ }
303
+ lines = append(lines, "---", "", "# Pull Requests", "")
304
+ if incomplete {
305
+ lines = append(lines, "> GitHub marked this search result set as incomplete.", "")
306
+ }
307
+ if total > 1000 {
308
+ lines = append(lines, "> GitHub Search exposes at most 1,000 results for a query; this page does not imply access beyond that provider ceiling.", "")
309
+ }
310
+ if len(pulls) == 0 {
311
+ lines = append(lines, "_No Pull Requests on this page._")
312
+ }
313
+ for _, pr := range pulls[:limit] {
314
+ href := pr.HTMLURL
315
+ if href == "" {
316
+ href = fmt.Sprintf("https://github.com/%s/%s/pull/%d", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), pr.Number)
317
+ }
318
+ state := strings.TrimSpace(pr.State)
319
+ if state == "" {
320
+ state = "unknown"
321
+ }
322
+ meta := []string{state}
323
+ if pr.User.Login != "" {
324
+ meta = append(meta, "@"+pr.User.Login)
325
+ }
326
+ if pr.UpdatedAt != "" {
327
+ meta = append(meta, "updated "+pr.UpdatedAt)
328
+ } else if pr.CreatedAt != "" {
329
+ meta = append(meta, "created "+pr.CreatedAt)
330
+ }
331
+ if labels := issueLabelNames(pr.Labels); len(labels) > 0 {
332
+ meta = append(meta, "labels: "+githubOverviewLabelList(labels, 3))
333
+ }
334
+ title, truncated := githubOverviewInlinePreview(pr.Title, 140)
335
+ if truncated {
336
+ title += "…"
337
+ }
338
+ lines = append(lines, fmt.Sprintf("- [#%d %s](%s) — %s", pr.Number, escapeMarkdownLinkText(title), href, strings.Join(meta, " · ")))
339
+ }
340
+ if nav := renderGitHubUIPageNavigation(target, links); len(nav) > 0 {
341
+ lines = append(lines, "", "## Navigation", "")
342
+ lines = append(lines, nav...)
343
+ }
344
+ return strings.TrimSpace(strings.Join(lines, "\n"))
345
+ }
346
+
347
+ func readGitHubPullRequest(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
348
+ if target == nil || target.Number <= 0 {
349
+ return "", fmt.Errorf("GitHub Pull Request URL is missing a number")
350
+ }
351
+ if issueID, ok, err := parseIssueBodySelector(target.Fragment); ok {
352
+ if err != nil {
353
+ return "", err
354
+ }
355
+ return readGitHubPullBody(ctx, client, target, issueID)
356
+ }
357
+ if commentID, ok, err := parseIssueCommentSelector(target.Fragment); ok {
358
+ if err != nil {
359
+ return "", err
360
+ }
361
+ return readGitHubIssueComment(ctx, client, target, commentID)
362
+ }
363
+ if commentID, ok, err := parsePullDiscussionSelector(target.Fragment); ok {
364
+ if err != nil {
365
+ return "", err
366
+ }
367
+ return readGitHubPullThreadSelector(ctx, client, target, commentID)
368
+ }
369
+ if reviewID, ok, err := parsePullReviewSelector(target.Fragment); ok {
370
+ if err != nil {
371
+ return "", err
372
+ }
373
+ return readGitHubPullReviewSelector(ctx, client, target, reviewID)
374
+ }
375
+ if target.Fragment != "" {
376
+ return "", fmt.Errorf("GitHub Pull Request fragment %q is not a supported native selector", target.Fragment)
377
+ }
378
+
379
+ base := fmt.Sprintf("/repos/%s/%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo))
380
+ prResp, err := client.REST(ctx, http.MethodGet, fmt.Sprintf("%s/pulls/%d", base, target.Number), "application/vnd.github+json")
381
+ if err != nil {
382
+ return "", err
383
+ }
384
+ var pr githubPullRequest
385
+ if err := json.Unmarshal(prResp.Body, &pr); err != nil {
386
+ return "", fmt.Errorf("decode GitHub Pull Request: %w", err)
387
+ }
388
+ issue, err := fetchGitHubIssue(ctx, client, target)
389
+ if err != nil {
390
+ return "", err
391
+ }
392
+ if issue.PullRequest == nil || issue.ID <= 0 {
393
+ return "", fmt.Errorf("GitHub Issue-side identity for Pull Request %s/%s#%d was unavailable", target.Owner, target.Repo, target.Number)
394
+ }
395
+ timeline, timelineMore, err := fetchGitHubPullTimelinePage(ctx, client, target)
396
+ if err != nil {
397
+ return "", err
398
+ }
399
+ reviews, reviewsMore, err := fetchGitHubPullReviewsPage(ctx, client, target)
400
+ if err != nil {
401
+ return "", err
402
+ }
403
+ comments, commentsMore, err := fetchGitHubPullReviewCommentsPage(ctx, client, target)
404
+ if err != nil {
405
+ return "", err
406
+ }
407
+ threads := groupGitHubPullThreads(comments)
408
+ enrichmentNote := ""
409
+ threadStatesMore := false
410
+ if client.hasToken() && len(threads) > 0 {
411
+ states, statesMore, enrichErr := fetchGitHubPullThreadStatesOverview(ctx, client, target)
412
+ if enrichErr != nil {
413
+ enrichmentNote = "Review-thread resolved/outdated enrichment was unavailable from GitHub GraphQL; REST thread content is still complete."
414
+ } else {
415
+ for i := range threads {
416
+ if state, ok := states[threads[i].Root.ID]; ok {
417
+ copyState := state
418
+ threads[i].State = &copyState
419
+ }
420
+ }
421
+ if statesMore {
422
+ enrichmentNote = "Review-thread state enrichment is limited to GitHub's first GraphQL page for this overview; exact thread reads may resolve deeper state without expanding the PR root."
423
+ threadStatesMore = true
424
+ }
425
+ }
426
+ }
427
+ availability := githubPullOverviewAvailability{
428
+ TimelineProviderMore: timelineMore,
429
+ ReviewsProviderMore: reviewsMore,
430
+ ReviewCommentsProviderMore: commentsMore,
431
+ ReviewCommentsReturned: len(comments),
432
+ ThreadStatesProviderMore: threadStatesMore,
433
+ }
434
+ return renderGitHubPullRequest(target, pr, issue.ID, timeline, reviews, threads, availability, enrichmentNote, client.hasToken()), nil
435
+ }
436
+
437
+ func readGitHubPullBody(ctx context.Context, client *GitHubClient, target *GitHubTarget, issueID int64) (string, error) {
438
+ issue, err := fetchGitHubIssue(ctx, client, target)
439
+ if err != nil {
440
+ return "", err
441
+ }
442
+ if issue.PullRequest == nil {
443
+ return "", fmt.Errorf("GitHub #%d is an Issue, not a Pull Request", target.Number)
444
+ }
445
+ if issue.ID != issueID {
446
+ return "", fmt.Errorf("GitHub Pull Request body selector issue-%d does not belong to %s/%s#%d (Issue-side id %d)", issueID, target.Owner, target.Repo, target.Number, issue.ID)
447
+ }
448
+ return renderGitHubPullBody(target, issue), nil
449
+ }
450
+
451
+ func renderGitHubPullBody(target *GitHubTarget, issue githubIssue) string {
452
+ selector := pullBaseURL(target) + "#issue-" + strconv.FormatInt(issue.ID, 10)
453
+ lines := []string{
454
+ "---",
455
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
456
+ fmt.Sprintf("pull_request: %d", target.Number),
457
+ fmt.Sprintf("issue_id: %d", issue.ID),
458
+ "title: " + yamlScalar(issue.Title),
459
+ }
460
+ if issue.User.Login != "" {
461
+ lines = append(lines, "author: "+yamlScalar("@"+issue.User.Login))
462
+ }
463
+ lines = append(lines, "url: "+yamlScalar(selector), "---", "", fmt.Sprintf("# Description of %s/%s#%d", target.Owner, target.Repo, target.Number), "")
464
+ if issue.Body == nil || strings.TrimSpace(stripInvisibleHTMLComments(*issue.Body)) == "" {
465
+ lines = append(lines, "_No description provided._")
466
+ } else {
467
+ lines = append(lines, stripInvisibleHTMLComments(*issue.Body))
468
+ }
469
+ return strings.TrimSpace(strings.Join(lines, "\n"))
470
+ }
471
+
472
+ func fetchGitHubPullTimelinePage(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubTimelineEvent, bool, error) {
473
+ endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d/timeline?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
474
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
475
+ if err != nil {
476
+ return nil, false, err
477
+ }
478
+ var events []githubTimelineEvent
479
+ if err := json.Unmarshal(resp.Body, &events); err != nil {
480
+ return nil, false, fmt.Errorf("decode GitHub Pull Request timeline: %w", err)
481
+ }
482
+ return events, strings.TrimSpace(resp.Links()["next"]) != "", nil
483
+ }
484
+
485
+ func fetchGitHubPullReviewsPage(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubPullReview, bool, error) {
486
+ endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
487
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
488
+ if err != nil {
489
+ return nil, false, err
490
+ }
491
+ var reviews []githubPullReview
492
+ if err := json.Unmarshal(resp.Body, &reviews); err != nil {
493
+ return nil, false, fmt.Errorf("decode GitHub Pull Request reviews: %w", err)
494
+ }
495
+ sort.SliceStable(reviews, func(i, j int) bool {
496
+ if reviews[i].SubmittedAt == reviews[j].SubmittedAt {
497
+ return reviews[i].ID < reviews[j].ID
498
+ }
499
+ return reviews[i].SubmittedAt < reviews[j].SubmittedAt
500
+ })
501
+ return reviews, strings.TrimSpace(resp.Links()["next"]) != "", nil
502
+ }
503
+
504
+ func fetchGitHubPullReviewCommentsPage(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubPullReviewComment, bool, error) {
505
+ endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/comments?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
506
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
507
+ if err != nil {
508
+ return nil, false, err
509
+ }
510
+ var comments []githubPullReviewComment
511
+ if err := json.Unmarshal(resp.Body, &comments); err != nil {
512
+ return nil, false, fmt.Errorf("decode GitHub Pull Request review comments: %w", err)
513
+ }
514
+ return comments, strings.TrimSpace(resp.Links()["next"]) != "", nil
515
+ }
516
+
517
+ func parsePullDiscussionSelector(fragment string) (int64, bool, error) {
518
+ if !strings.HasPrefix(fragment, "discussion_r") {
519
+ return 0, false, nil
520
+ }
521
+ raw := strings.TrimPrefix(fragment, "discussion_r")
522
+ id, err := strconv.ParseInt(raw, 10, 64)
523
+ if err != nil || id <= 0 {
524
+ return 0, true, fmt.Errorf("invalid GitHub review-comment selector %q", fragment)
525
+ }
526
+ return id, true, nil
527
+ }
528
+
529
+ func parsePullReviewSelector(fragment string) (int64, bool, error) {
530
+ if !strings.HasPrefix(fragment, "pullrequestreview-") {
531
+ return 0, false, nil
532
+ }
533
+ raw := strings.TrimPrefix(fragment, "pullrequestreview-")
534
+ id, err := strconv.ParseInt(raw, 10, 64)
535
+ if err != nil || id <= 0 {
536
+ return 0, true, fmt.Errorf("invalid GitHub Pull Request review selector %q", fragment)
537
+ }
538
+ return id, true, nil
539
+ }
540
+
541
+ func fetchGitHubPullReviews(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubPullReview, error) {
542
+ endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
543
+ pages, err := client.RESTPages(ctx, endpoint, "application/vnd.github+json")
544
+ if err != nil {
545
+ return nil, err
546
+ }
547
+ out := []githubPullReview{}
548
+ for _, page := range pages {
549
+ var batch []githubPullReview
550
+ if err := json.Unmarshal(page.Body, &batch); err != nil {
551
+ return nil, fmt.Errorf("decode GitHub Pull Request reviews: %w", err)
552
+ }
553
+ out = append(out, batch...)
554
+ }
555
+ sort.SliceStable(out, func(i, j int) bool {
556
+ if out[i].SubmittedAt == out[j].SubmittedAt {
557
+ return out[i].ID < out[j].ID
558
+ }
559
+ return out[i].SubmittedAt < out[j].SubmittedAt
560
+ })
561
+ return out, nil
562
+ }
563
+
564
+ func fetchGitHubPullReviewComments(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubPullReviewComment, error) {
565
+ endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/comments?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
566
+ pages, err := client.RESTPages(ctx, endpoint, "application/vnd.github+json")
567
+ if err != nil {
568
+ return nil, err
569
+ }
570
+ out := []githubPullReviewComment{}
571
+ for _, page := range pages {
572
+ var batch []githubPullReviewComment
573
+ if err := json.Unmarshal(page.Body, &batch); err != nil {
574
+ return nil, fmt.Errorf("decode GitHub Pull Request review comments: %w", err)
575
+ }
576
+ out = append(out, batch...)
577
+ }
578
+ return out, nil
579
+ }
580
+
581
+ func groupGitHubPullThreads(comments []githubPullReviewComment) []githubPullThread {
582
+ byID := make(map[int64]githubPullReviewComment, len(comments))
583
+ for _, comment := range comments {
584
+ byID[comment.ID] = comment
585
+ }
586
+ rootID := func(comment githubPullReviewComment) int64 {
587
+ seen := map[int64]struct{}{comment.ID: {}}
588
+ current := comment
589
+ for current.InReplyToID != nil && *current.InReplyToID > 0 {
590
+ parentID := *current.InReplyToID
591
+ if _, cycle := seen[parentID]; cycle {
592
+ break
593
+ }
594
+ seen[parentID] = struct{}{}
595
+ parent, ok := byID[parentID]
596
+ if !ok {
597
+ return parentID
598
+ }
599
+ current = parent
600
+ }
601
+ return current.ID
602
+ }
603
+ groups := map[int64][]githubPullReviewComment{}
604
+ for _, comment := range comments {
605
+ id := rootID(comment)
606
+ groups[id] = append(groups[id], comment)
607
+ }
608
+ threads := make([]githubPullThread, 0, len(groups))
609
+ for id, group := range groups {
610
+ root, ok := byID[id]
611
+ if !ok {
612
+ // A missing root should not discard substantive replies. Promote the
613
+ // earliest visible reply and retain the rest in deterministic order.
614
+ sortPullComments(group)
615
+ root = group[0]
616
+ }
617
+ replies := make([]githubPullReviewComment, 0, len(group)-1)
618
+ for _, comment := range group {
619
+ if comment.ID != root.ID {
620
+ replies = append(replies, comment)
621
+ }
622
+ }
623
+ sortPullComments(replies)
624
+ threads = append(threads, githubPullThread{Root: root, Replies: replies})
625
+ }
626
+ sort.SliceStable(threads, func(i, j int) bool {
627
+ if threads[i].Root.CreatedAt == threads[j].Root.CreatedAt {
628
+ return threads[i].Root.ID < threads[j].Root.ID
629
+ }
630
+ return threads[i].Root.CreatedAt < threads[j].Root.CreatedAt
631
+ })
632
+ return threads
633
+ }
634
+
635
+ func sortPullComments(comments []githubPullReviewComment) {
636
+ sort.SliceStable(comments, func(i, j int) bool {
637
+ if comments[i].CreatedAt == comments[j].CreatedAt {
638
+ return comments[i].ID < comments[j].ID
639
+ }
640
+ return comments[i].CreatedAt < comments[j].CreatedAt
641
+ })
642
+ }
643
+
644
+ func fetchGitHubPullThreadStatesOverview(ctx context.Context, client *GitHubClient, target *GitHubTarget) (map[int64]githubPullThreadState, bool, error) {
645
+ const query = `query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100){nodes{isResolved isOutdated resolvedBy{login} comments(first:1){nodes{fullDatabaseId}}}pageInfo{hasNextPage}}}}}`
646
+ var data struct {
647
+ Repository struct {
648
+ PullRequest *struct {
649
+ ReviewThreads struct {
650
+ Nodes []struct {
651
+ IsResolved bool `json:"isResolved"`
652
+ IsOutdated bool `json:"isOutdated"`
653
+ ResolvedBy *struct {
654
+ Login string `json:"login"`
655
+ } `json:"resolvedBy"`
656
+ Comments struct {
657
+ Nodes []struct {
658
+ FullDatabaseID string `json:"fullDatabaseId"`
659
+ } `json:"nodes"`
660
+ } `json:"comments"`
661
+ } `json:"nodes"`
662
+ PageInfo struct {
663
+ HasNextPage bool `json:"hasNextPage"`
664
+ } `json:"pageInfo"`
665
+ } `json:"reviewThreads"`
666
+ } `json:"pullRequest"`
667
+ } `json:"repository"`
668
+ }
669
+ if err := client.GraphQL(ctx, query, map[string]any{"owner": target.Owner, "repo": target.Repo, "number": target.Number}, &data); err != nil {
670
+ return nil, false, err
671
+ }
672
+ if data.Repository.PullRequest == nil {
673
+ return nil, false, fmt.Errorf("GitHub GraphQL Pull Request was unavailable")
674
+ }
675
+ states := map[int64]githubPullThreadState{}
676
+ connection := data.Repository.PullRequest.ReviewThreads
677
+ for _, node := range connection.Nodes {
678
+ if len(node.Comments.Nodes) == 0 {
679
+ continue
680
+ }
681
+ id, err := strconv.ParseInt(node.Comments.Nodes[0].FullDatabaseID, 10, 64)
682
+ if err != nil || id <= 0 {
683
+ continue
684
+ }
685
+ state := githubPullThreadState{Resolved: node.IsResolved, Outdated: node.IsOutdated}
686
+ if node.ResolvedBy != nil {
687
+ state.ResolvedBy = node.ResolvedBy.Login
688
+ }
689
+ states[id] = state
690
+ }
691
+ return states, connection.PageInfo.HasNextPage, nil
692
+ }
693
+
694
+ func fetchGitHubPullThreadStates(ctx context.Context, client *GitHubClient, target *GitHubTarget) (map[int64]githubPullThreadState, error) {
695
+ const query = `query($owner:String!,$repo:String!,$number:Int!,$after:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){nodes{isResolved isOutdated resolvedBy{login} comments(first:1){nodes{fullDatabaseId}}}pageInfo{hasNextPage endCursor}}}}}`
696
+ states := map[int64]githubPullThreadState{}
697
+ var after any
698
+ for {
699
+ variables := map[string]any{"owner": target.Owner, "repo": target.Repo, "number": target.Number, "after": after}
700
+ var data struct {
701
+ Repository struct {
702
+ PullRequest *struct {
703
+ ReviewThreads struct {
704
+ Nodes []struct {
705
+ IsResolved bool `json:"isResolved"`
706
+ IsOutdated bool `json:"isOutdated"`
707
+ ResolvedBy *struct {
708
+ Login string `json:"login"`
709
+ } `json:"resolvedBy"`
710
+ Comments struct {
711
+ Nodes []struct {
712
+ FullDatabaseID string `json:"fullDatabaseId"`
713
+ } `json:"nodes"`
714
+ } `json:"comments"`
715
+ } `json:"nodes"`
716
+ PageInfo struct {
717
+ HasNextPage bool `json:"hasNextPage"`
718
+ EndCursor string `json:"endCursor"`
719
+ } `json:"pageInfo"`
720
+ } `json:"reviewThreads"`
721
+ } `json:"pullRequest"`
722
+ } `json:"repository"`
723
+ }
724
+ if err := client.GraphQL(ctx, query, variables, &data); err != nil {
725
+ return nil, err
726
+ }
727
+ if data.Repository.PullRequest == nil {
728
+ return nil, fmt.Errorf("GitHub GraphQL Pull Request was unavailable")
729
+ }
730
+ connection := data.Repository.PullRequest.ReviewThreads
731
+ for _, node := range connection.Nodes {
732
+ if len(node.Comments.Nodes) == 0 {
733
+ continue
734
+ }
735
+ id, err := strconv.ParseInt(node.Comments.Nodes[0].FullDatabaseID, 10, 64)
736
+ if err != nil || id <= 0 {
737
+ continue
738
+ }
739
+ state := githubPullThreadState{Resolved: node.IsResolved, Outdated: node.IsOutdated}
740
+ if node.ResolvedBy != nil {
741
+ state.ResolvedBy = node.ResolvedBy.Login
742
+ }
743
+ states[id] = state
744
+ }
745
+ if !connection.PageInfo.HasNextPage {
746
+ break
747
+ }
748
+ if connection.PageInfo.EndCursor == "" {
749
+ return nil, fmt.Errorf("GitHub GraphQL review-thread pagination omitted an end cursor")
750
+ }
751
+ after = connection.PageInfo.EndCursor
752
+ }
753
+ return states, nil
754
+ }
755
+
756
+ func readGitHubPullThreadSelector(ctx context.Context, client *GitHubClient, target *GitHubTarget, commentID int64) (string, error) {
757
+ endpoint := fmt.Sprintf("/repos/%s/%s/pulls/comments/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), commentID)
758
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
759
+ if err != nil {
760
+ return "", err
761
+ }
762
+ var selected githubPullReviewComment
763
+ if err := json.Unmarshal(resp.Body, &selected); err != nil {
764
+ return "", fmt.Errorf("decode GitHub review comment: %w", err)
765
+ }
766
+ if selected.PullRequestURL != "" && !strings.HasSuffix(strings.TrimRight(selected.PullRequestURL, "/"), fmt.Sprintf("/repos/%s/%s/pulls/%d", target.Owner, target.Repo, target.Number)) {
767
+ return "", fmt.Errorf("GitHub review comment %d does not belong to %s/%s#%d", commentID, target.Owner, target.Repo, target.Number)
768
+ }
769
+ comments, err := fetchGitHubPullReviewComments(ctx, client, target)
770
+ if err != nil {
771
+ return "", err
772
+ }
773
+ threads := groupGitHubPullThreads(comments)
774
+ for _, thread := range threads {
775
+ if thread.Root.ID == selected.ID || pullThreadContains(thread, selected.ID) {
776
+ if client.hasToken() {
777
+ if states, enrichErr := fetchGitHubPullThreadStates(ctx, client, target); enrichErr == nil {
778
+ if state, ok := states[thread.Root.ID]; ok {
779
+ thread.State = &state
780
+ }
781
+ }
782
+ }
783
+ return renderGitHubPullThreadSelector(target, thread, selected.ID), nil
784
+ }
785
+ }
786
+ // If list pagination changed between reads, preserve the selected comment
787
+ // rather than pretending it did not exist.
788
+ return renderGitHubPullThreadSelector(target, githubPullThread{Root: selected}, selected.ID), nil
789
+ }
790
+
791
+ func pullThreadContains(thread githubPullThread, id int64) bool {
792
+ for _, reply := range thread.Replies {
793
+ if reply.ID == id {
794
+ return true
795
+ }
796
+ }
797
+ return false
798
+ }
799
+
800
+ func readGitHubPullReviewSelector(ctx context.Context, client *GitHubClient, target *GitHubTarget, reviewID int64) (string, error) {
801
+ base := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number, reviewID)
802
+ resp, err := client.REST(ctx, http.MethodGet, base, "application/vnd.github+json")
803
+ if err != nil {
804
+ return "", err
805
+ }
806
+ var review githubPullReview
807
+ if err := json.Unmarshal(resp.Body, &review); err != nil {
808
+ return "", fmt.Errorf("decode GitHub Pull Request review: %w", err)
809
+ }
810
+ pages, err := client.RESTPages(ctx, base+"/comments?per_page=100", "application/vnd.github+json")
811
+ if err != nil {
812
+ return "", err
813
+ }
814
+ comments := []githubPullReviewComment{}
815
+ for _, page := range pages {
816
+ var batch []githubPullReviewComment
817
+ if err := json.Unmarshal(page.Body, &batch); err != nil {
818
+ return "", fmt.Errorf("decode GitHub Pull Request review comments: %w", err)
819
+ }
820
+ comments = append(comments, batch...)
821
+ }
822
+ return renderGitHubPullReviewSelector(target, review, groupGitHubPullThreads(comments)), nil
823
+ }
824
+
825
+ func renderGitHubPullRequest(target *GitHubTarget, pr githubPullRequest, issueID int64, timeline []githubTimelineEvent, reviews []githubPullReview, threads []githubPullThread, availability githubPullOverviewAvailability, enrichmentNote string, hasToken bool) string {
826
+ comments, events := splitPullTimelineForOverview(timeline)
827
+ commentLimit := minInt(3, len(comments))
828
+ eventLimit := minInt(6, len(events))
829
+ reviewLimit := minInt(4, len(reviews))
830
+ threadLimit := minInt(4, len(threads))
831
+ bodyLimit := 900
832
+ for {
833
+ out := renderGitHubPullOverviewWithLimits(target, pr, issueID, comments, events, reviews, threads, availability, enrichmentNote, hasToken, commentLimit, eventLimit, reviewLimit, threadLimit, bodyLimit)
834
+ if githubOverviewFits(out) {
835
+ return out
836
+ }
837
+ switch {
838
+ case eventLimit > 0:
839
+ eventLimit--
840
+ case threadLimit > 1:
841
+ threadLimit--
842
+ case reviewLimit > 1:
843
+ reviewLimit--
844
+ case bodyLimit > 400:
845
+ bodyLimit = 400
846
+ case bodyLimit > 200:
847
+ bodyLimit = 200
848
+ case commentLimit > 1:
849
+ commentLimit--
850
+ default:
851
+ // Mandatory metadata, one representative human comment per available
852
+ // conversation, and exact navigation win over the shared soft target.
853
+ return out
854
+ }
855
+ }
856
+ }
857
+
858
+ func renderGitHubPullOverviewWithLimits(target *GitHubTarget, pr githubPullRequest, issueID int64, comments, events []githubTimelineEvent, reviews []githubPullReview, threads []githubPullThread, availability githubPullOverviewAvailability, enrichmentNote string, hasToken bool, commentLimit, eventLimit, reviewLimit, threadLimit, bodyLimit int) string {
859
+ commentLimit = minInt(commentLimit, len(comments))
860
+ eventLimit = minInt(eventLimit, len(events))
861
+ reviewLimit = minInt(reviewLimit, len(reviews))
862
+ threadLimit = minInt(threadLimit, len(threads))
863
+ titlePreview, titleTruncated := githubOverviewInlinePreview(pr.Title, 180)
864
+ if titleTruncated {
865
+ titlePreview += "…"
866
+ }
867
+ lines := []string{
868
+ "---",
869
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
870
+ fmt.Sprintf("number: %d", pr.Number),
871
+ fmt.Sprintf("issue_id: %d", issueID),
872
+ "state: " + yamlScalar(pullDisplayState(pr)),
873
+ "title: " + yamlScalar(titlePreview),
874
+ "overview: true",
875
+ }
876
+ if titleTruncated {
877
+ lines = append(lines, "title_preview_truncated: true")
878
+ }
879
+ if pr.ID > 0 {
880
+ lines = append(lines, fmt.Sprintf("pull_request_id: %d", pr.ID))
881
+ }
882
+ if pr.Draft {
883
+ lines = append(lines, "draft: true")
884
+ }
885
+ if pr.User.Login != "" {
886
+ lines = append(lines, "author: "+yamlScalar("@"+pr.User.Login))
887
+ }
888
+ if pr.Base.Label != "" {
889
+ base, truncated := githubOverviewInlinePreview(pr.Base.Label, 140)
890
+ if truncated {
891
+ base += "…"
892
+ }
893
+ lines = append(lines, "base: "+yamlScalar(base))
894
+ }
895
+ if pr.Head.Label != "" {
896
+ head, truncated := githubOverviewInlinePreview(pr.Head.Label, 140)
897
+ if truncated {
898
+ head += "…"
899
+ }
900
+ lines = append(lines, "head: "+yamlScalar(head))
901
+ }
902
+ for _, value := range []struct{ key, value string }{{"created", pr.CreatedAt}, {"updated", pr.UpdatedAt}, {"closed", pr.ClosedAt}, {"merged", pr.MergedAt}} {
903
+ if value.value != "" {
904
+ lines = append(lines, value.key+": "+yamlScalar(value.value))
905
+ }
906
+ }
907
+ lines = append(lines,
908
+ fmt.Sprintf("commits: %d", pr.Commits),
909
+ fmt.Sprintf("changed_files: %d", pr.ChangedFiles),
910
+ fmt.Sprintf("additions: %d", pr.Additions),
911
+ fmt.Sprintf("deletions: %d", pr.Deletions),
912
+ fmt.Sprintf("conversation_comments_reported: %d", pr.Comments),
913
+ fmt.Sprintf("conversation_comments_returned: %d", len(comments)),
914
+ fmt.Sprintf("conversation_comments_indexed: %d", commentLimit),
915
+ fmt.Sprintf("conversation_comments_omitted: %d", len(comments)-commentLimit),
916
+ fmt.Sprintf("conversation_events_returned: %d", len(events)),
917
+ fmt.Sprintf("conversation_events_indexed: %d", eventLimit),
918
+ fmt.Sprintf("conversation_events_omitted: %d", len(events)-eventLimit),
919
+ fmt.Sprintf("conversation_items_returned: %d", len(comments)+len(events)),
920
+ fmt.Sprintf("conversation_items_indexed: %d", commentLimit+eventLimit),
921
+ fmt.Sprintf("conversation_items_omitted: %d", len(comments)+len(events)-commentLimit-eventLimit),
922
+ fmt.Sprintf("reviews_returned: %d", len(reviews)),
923
+ fmt.Sprintf("reviews_indexed: %d", reviewLimit),
924
+ fmt.Sprintf("reviews_omitted: %d", len(reviews)-reviewLimit),
925
+ fmt.Sprintf("review_comments_reported: %d", pr.ReviewComments),
926
+ fmt.Sprintf("review_comments_returned: %d", availability.ReviewCommentsReturned),
927
+ fmt.Sprintf("threads_returned: %d", len(threads)),
928
+ fmt.Sprintf("threads_indexed: %d", threadLimit),
929
+ fmt.Sprintf("threads_omitted: %d", len(threads)-threadLimit),
930
+ )
931
+ if availability.TimelineProviderMore {
932
+ lines = append(lines, "conversation_provider_more_available: true")
933
+ } else if pr.Comments > len(comments) {
934
+ lines = append(lines, "conversation_provider_complete: false")
935
+ }
936
+ if availability.ReviewsProviderMore {
937
+ lines = append(lines, "reviews_provider_more_available: true")
938
+ }
939
+ if availability.ReviewCommentsProviderMore {
940
+ lines = append(lines, "review_comments_provider_more_available: true")
941
+ } else if pr.ReviewComments > availability.ReviewCommentsReturned {
942
+ lines = append(lines, "review_comments_provider_complete: false")
943
+ }
944
+ if len(threads) > 0 && !hasToken {
945
+ lines = append(lines, "thread_state_enrichment: unavailable_without_auth")
946
+ } else if availability.ThreadStatesProviderMore {
947
+ lines = append(lines, "thread_state_enrichment: partial_provider_page")
948
+ } else if enrichmentNote != "" {
949
+ lines = append(lines, "thread_state_enrichment: unavailable")
950
+ }
951
+ if pr.HTMLURL != "" {
952
+ lines = append(lines, "url: "+yamlScalar(pr.HTMLURL))
953
+ }
954
+ lines = append(lines, "---", "", fmt.Sprintf("# #%d %s", pr.Number, titlePreview), "", "> Pull Request overview: description and child conversations are previewed/indexed; use the exact URLs below for full scoped content.")
955
+
956
+ bodySelector := pullBaseURL(target) + "#issue-" + strconv.FormatInt(issueID, 10)
957
+ lines = append(lines, "", "## Description preview", "")
958
+ if pr.Body == nil || strings.TrimSpace(stripInvisibleHTMLComments(*pr.Body)) == "" {
959
+ lines = append(lines, "_No description provided._")
960
+ } else {
961
+ preview, truncated := githubOverviewPreview(stripInvisibleHTMLComments(*pr.Body), bodyLimit)
962
+ lines = append(lines, preview)
963
+ if truncated {
964
+ lines = append(lines, "", "> Description preview locally truncated for this overview.")
965
+ }
966
+ }
967
+ lines = append(lines, "> Full description: "+bodySelector)
968
+
969
+ lines = append(lines, "", "## Conversation comment index", "")
970
+ if len(comments) == 0 {
971
+ lines = append(lines, "_No ordinary conversation comments returned on this provider page._")
972
+ }
973
+ for _, event := range comments[:commentLimit] {
974
+ comment := githubIssueComment{ID: event.ID, Body: event.Body, HTMLURL: event.HTMLURL, User: event.User, AuthorAssociation: event.AuthorAssociation, CreatedAt: event.CreatedAt, UpdatedAt: event.UpdatedAt, IsPinned: event.IsPinned, Minimized: event.Minimized, MinimizedReason: event.MinimizedReason}
975
+ lines = append(lines, renderPullConversationCommentIndex(target, comment)...)
976
+ }
977
+ if note := githubLocalOmissionNote("conversation comments", len(comments)-commentLimit); note != "" {
978
+ lines = append(lines, "", note)
979
+ }
980
+
981
+ lines = append(lines, "", "## Timeline event index", "")
982
+ if len(events) == 0 {
983
+ lines = append(lines, "_No substantive non-comment timeline events returned on this provider page._")
984
+ }
985
+ for _, event := range events[:eventLimit] {
986
+ if rendered, ok := renderPullTimelineState(event); ok {
987
+ lines = append(lines, rendered)
988
+ }
989
+ }
990
+ if note := githubLocalOmissionNote("timeline events", len(events)-eventLimit); note != "" {
991
+ lines = append(lines, "", note)
992
+ }
993
+ if availability.TimelineProviderMore {
994
+ lines = append(lines, "", "> GitHub has more conversation timeline items beyond the provider page fetched for this overview.")
995
+ } else if pr.Comments > len(comments) {
996
+ lines = append(lines, "", fmt.Sprintf("> GitHub reports %d conversation comments, while the fetched timeline page returned %d comment events; this provider-incomplete state is separate from local overview omission.", pr.Comments, len(comments)))
997
+ }
998
+
999
+ lines = append(lines, "", "## Review index", "")
1000
+ if len(reviews) == 0 {
1001
+ lines = append(lines, "_No submitted reviews returned on this provider page._")
1002
+ }
1003
+ for _, review := range reviews[:reviewLimit] {
1004
+ lines = append(lines, renderPullReviewIndex(target, review)...)
1005
+ }
1006
+ if note := githubLocalOmissionNote("reviews", len(reviews)-reviewLimit); note != "" {
1007
+ lines = append(lines, "", note)
1008
+ }
1009
+ if availability.ReviewsProviderMore {
1010
+ lines = append(lines, "", "> GitHub has more submitted reviews beyond the provider page fetched for this overview.")
1011
+ }
1012
+
1013
+ lines = append(lines, "", "## Inline thread index", "")
1014
+ if len(threads) == 0 {
1015
+ lines = append(lines, "_No inline review threads returned on this provider page._")
1016
+ }
1017
+ for _, thread := range threads[:threadLimit] {
1018
+ lines = append(lines, renderPullThreadIndex(target, thread)...)
1019
+ }
1020
+ if note := githubLocalOmissionNote("inline review threads", len(threads)-threadLimit); note != "" {
1021
+ lines = append(lines, "", note)
1022
+ }
1023
+ if availability.ReviewCommentsProviderMore {
1024
+ lines = append(lines, "", "> GitHub has more inline review comments beyond the provider page fetched for this overview; additional threads may therefore exist upstream.")
1025
+ } else if pr.ReviewComments > availability.ReviewCommentsReturned {
1026
+ lines = append(lines, "", "> GitHub reports more inline review comments than were returned by the fetched provider page; this provider-incomplete state is separate from local overview omission.")
1027
+ }
1028
+ if enrichmentNote != "" {
1029
+ lines = append(lines, "", "> "+enrichmentNote)
1030
+ }
1031
+
1032
+ base := pullBaseURL(target)
1033
+ lines = append(lines, "", "## Useful GitHub URLs", "", "- Full description: "+bodySelector, "- Files changed: "+base+"/files", "- Commits: "+base+"/commits", "- Checks: "+base+"/checks")
1034
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1035
+ }
1036
+
1037
+ func splitPullTimelineForOverview(timeline []githubTimelineEvent) ([]githubTimelineEvent, []githubTimelineEvent) {
1038
+ comments := make([]githubTimelineEvent, 0, len(timeline))
1039
+ events := make([]githubTimelineEvent, 0, len(timeline))
1040
+ for _, event := range timeline {
1041
+ if event.Event == "commented" {
1042
+ comments = append(comments, event)
1043
+ continue
1044
+ }
1045
+ if _, ok := renderPullTimelineState(event); ok {
1046
+ events = append(events, event)
1047
+ }
1048
+ }
1049
+ return comments, events
1050
+ }
1051
+
1052
+ func renderPullConversationCommentIndex(target *GitHubTarget, comment githubIssueComment) []string {
1053
+ heading := fmt.Sprintf("### Comment `%d`", comment.ID)
1054
+ if comment.User.Login != "" {
1055
+ heading += " by @" + comment.User.Login
1056
+ }
1057
+ if comment.CreatedAt != "" {
1058
+ heading += " — " + comment.CreatedAt
1059
+ }
1060
+ body := strings.Join(renderIssueCommentBody(comment, comment.IsPinned), "\n")
1061
+ preview, truncated := githubOverviewPreview(body, githubIndexPreviewRunes)
1062
+ lines := []string{heading, ""}
1063
+ for _, line := range strings.Split(preview, "\n") {
1064
+ lines = append(lines, "> "+line)
1065
+ }
1066
+ if truncated {
1067
+ lines = append(lines, "> _Preview locally truncated._")
1068
+ }
1069
+ lines = append(lines, "", "Selector: "+pullIssueCommentSelectorURL(target, comment), "")
1070
+ return lines
1071
+ }
1072
+
1073
+ func renderPullReviewIndex(target *GitHubTarget, review githubPullReview) []string {
1074
+ heading := fmt.Sprintf("### Review `%d`", review.ID)
1075
+ if review.State != "" {
1076
+ heading += " — " + strings.ToUpper(review.State)
1077
+ }
1078
+ if review.User.Login != "" {
1079
+ heading += " by @" + review.User.Login
1080
+ }
1081
+ if review.SubmittedAt != "" {
1082
+ heading += " — " + review.SubmittedAt
1083
+ }
1084
+ lines := []string{heading, ""}
1085
+ if review.Body != nil {
1086
+ body := strings.TrimSpace(stripInvisibleHTMLComments(*review.Body))
1087
+ if body != "" {
1088
+ preview, truncated := githubOverviewPreview(body, githubIndexPreviewRunes)
1089
+ for _, line := range strings.Split(preview, "\n") {
1090
+ lines = append(lines, "> "+line)
1091
+ }
1092
+ if truncated {
1093
+ lines = append(lines, "> _Preview locally truncated._")
1094
+ }
1095
+ }
1096
+ }
1097
+ lines = append(lines, "", "Selector: "+pullReviewSelectorURL(target, review), "")
1098
+ return lines
1099
+ }
1100
+
1101
+ func renderPullThreadIndex(target *GitHubTarget, thread githubPullThread) []string {
1102
+ root := thread.Root
1103
+ heading := fmt.Sprintf("### Thread `%d`", root.ID)
1104
+ if root.User.Login != "" {
1105
+ heading += " by @" + root.User.Login
1106
+ }
1107
+ if root.CreatedAt != "" {
1108
+ heading += " — " + root.CreatedAt
1109
+ }
1110
+ lines := []string{heading}
1111
+ if coord := pullCommentCoordinate(root); coord != "" {
1112
+ lines = append(lines, "Coordinate: "+coord)
1113
+ }
1114
+ if thread.State != nil {
1115
+ state := "unresolved"
1116
+ if thread.State.Resolved {
1117
+ state = "resolved"
1118
+ }
1119
+ if thread.State.Outdated {
1120
+ state += " · outdated"
1121
+ }
1122
+ if thread.State.ResolvedBy != "" {
1123
+ state += " · by @" + thread.State.ResolvedBy
1124
+ }
1125
+ lines = append(lines, "State: "+state)
1126
+ }
1127
+ if len(thread.Replies) > 0 {
1128
+ lines = append(lines, fmt.Sprintf("Replies: %d", len(thread.Replies)))
1129
+ }
1130
+ lines = append(lines, "")
1131
+ body := "_Review comment body is unavailable or deleted._"
1132
+ if root.Body != nil {
1133
+ body = strings.TrimSpace(stripInvisibleHTMLComments(*root.Body))
1134
+ if body == "" {
1135
+ body = "_Review comment is empty after removing invisible GitHub markup._"
1136
+ }
1137
+ }
1138
+ preview, truncated := githubOverviewPreview(body, githubIndexPreviewRunes)
1139
+ for _, line := range strings.Split(preview, "\n") {
1140
+ lines = append(lines, "> "+line)
1141
+ }
1142
+ if truncated {
1143
+ lines = append(lines, "> _Preview locally truncated._")
1144
+ }
1145
+ lines = append(lines, "", "Selector: "+pullThreadSelectorURL(target, root), "")
1146
+ return lines
1147
+ }
1148
+
1149
+ func pullBaseURL(target *GitHubTarget) string {
1150
+ return fmt.Sprintf("https://github.com/%s/%s/pull/%d", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), target.Number)
1151
+ }
1152
+
1153
+ func pullIssueCommentSelectorURL(target *GitHubTarget, comment githubIssueComment) string {
1154
+ if strings.TrimSpace(comment.HTMLURL) != "" {
1155
+ return comment.HTMLURL
1156
+ }
1157
+ return pullBaseURL(target) + "#issuecomment-" + strconv.FormatInt(comment.ID, 10)
1158
+ }
1159
+
1160
+ func pullReviewSelectorURL(target *GitHubTarget, review githubPullReview) string {
1161
+ if strings.TrimSpace(review.HTMLURL) != "" {
1162
+ return review.HTMLURL
1163
+ }
1164
+ return pullBaseURL(target) + "#pullrequestreview-" + strconv.FormatInt(review.ID, 10)
1165
+ }
1166
+
1167
+ func pullThreadSelectorURL(target *GitHubTarget, comment githubPullReviewComment) string {
1168
+ if strings.TrimSpace(comment.HTMLURL) != "" {
1169
+ return comment.HTMLURL
1170
+ }
1171
+ return pullBaseURL(target) + "#discussion_r" + strconv.FormatInt(comment.ID, 10)
1172
+ }
1173
+
1174
+ func minInt(a, b int) int {
1175
+ if a < b {
1176
+ return a
1177
+ }
1178
+ return b
1179
+ }
1180
+
1181
+ func pullDisplayState(pr githubPullRequest) string {
1182
+ if pr.Merged || pr.MergedAt != "" {
1183
+ return "merged"
1184
+ }
1185
+ if pr.Draft && pr.State == "open" {
1186
+ return "draft"
1187
+ }
1188
+ if pr.State != "" {
1189
+ return pr.State
1190
+ }
1191
+ return "unknown"
1192
+ }
1193
+
1194
+ func renderPullReview(review githubPullReview) []string {
1195
+ heading := "### " + strings.ToUpper(review.State)
1196
+ if review.User.Login != "" {
1197
+ heading += " by @" + review.User.Login
1198
+ }
1199
+ if review.SubmittedAt != "" {
1200
+ heading += " — " + review.SubmittedAt
1201
+ }
1202
+ lines := []string{heading}
1203
+ meta := []string{}
1204
+ if review.ID != 0 {
1205
+ meta = append(meta, fmt.Sprintf("review %d", review.ID))
1206
+ }
1207
+ if review.CommitID != "" {
1208
+ meta = append(meta, "commit `"+shortSHA(review.CommitID)+"`")
1209
+ }
1210
+ if review.AuthorAssociation != "" {
1211
+ meta = append(meta, "association "+review.AuthorAssociation)
1212
+ }
1213
+ if review.HTMLURL != "" {
1214
+ meta = append(meta, review.HTMLURL)
1215
+ }
1216
+ if len(meta) > 0 {
1217
+ lines = append(lines, "", "_"+strings.Join(meta, " · ")+"_")
1218
+ }
1219
+ if review.Body != nil {
1220
+ body := strings.TrimSpace(stripInvisibleHTMLComments(*review.Body))
1221
+ if body != "" {
1222
+ lines = append(lines, "", body)
1223
+ }
1224
+ }
1225
+ lines = append(lines, "")
1226
+ return lines
1227
+ }
1228
+
1229
+ func renderPullThread(thread githubPullThread, selectedID int64) []string {
1230
+ root := thread.Root
1231
+ coord := pullCommentCoordinate(root)
1232
+ heading := fmt.Sprintf("### Thread %d", root.ID)
1233
+ if coord != "" {
1234
+ heading += " — " + coord
1235
+ }
1236
+ if thread.State != nil {
1237
+ states := []string{}
1238
+ if thread.State.Resolved {
1239
+ states = append(states, "resolved")
1240
+ } else {
1241
+ states = append(states, "unresolved")
1242
+ }
1243
+ if thread.State.Outdated {
1244
+ states = append(states, "outdated")
1245
+ }
1246
+ if thread.State.ResolvedBy != "" {
1247
+ states = append(states, "by @"+thread.State.ResolvedBy)
1248
+ }
1249
+ heading += " · " + strings.Join(states, " · ")
1250
+ }
1251
+ lines := []string{heading, ""}
1252
+ lines = append(lines, renderPullReviewComment(root, selectedID == root.ID)...)
1253
+ for _, reply := range thread.Replies {
1254
+ lines = append(lines, "", "#### Reply", "")
1255
+ lines = append(lines, renderPullReviewComment(reply, selectedID == reply.ID)...)
1256
+ }
1257
+ lines = append(lines, "")
1258
+ return lines
1259
+ }
1260
+
1261
+ func renderPullReviewComment(comment githubPullReviewComment, selected bool) []string {
1262
+ meta := []string{}
1263
+ if comment.User.Login != "" {
1264
+ meta = append(meta, "@"+comment.User.Login)
1265
+ }
1266
+ if comment.CreatedAt != "" {
1267
+ meta = append(meta, comment.CreatedAt)
1268
+ }
1269
+ if comment.AuthorAssociation != "" {
1270
+ meta = append(meta, comment.AuthorAssociation)
1271
+ }
1272
+ if selected {
1273
+ meta = append(meta, "selected")
1274
+ }
1275
+ if comment.HTMLURL != "" {
1276
+ meta = append(meta, comment.HTMLURL)
1277
+ }
1278
+ lines := []string{}
1279
+ if len(meta) > 0 {
1280
+ lines = append(lines, "_"+strings.Join(meta, " · ")+"_", "")
1281
+ }
1282
+ if comment.Body == nil {
1283
+ lines = append(lines, "_Review comment body is unavailable or deleted._")
1284
+ } else {
1285
+ body := strings.TrimSpace(stripInvisibleHTMLComments(*comment.Body))
1286
+ if body == "" {
1287
+ body = "_Review comment is empty after removing invisible GitHub markup._"
1288
+ }
1289
+ lines = append(lines, body)
1290
+ }
1291
+ return lines
1292
+ }
1293
+
1294
+ func pullCommentCoordinate(comment githubPullReviewComment) string {
1295
+ if comment.Path == "" {
1296
+ return ""
1297
+ }
1298
+ line := comment.Line
1299
+ if line == nil {
1300
+ line = comment.OriginalLine
1301
+ }
1302
+ start := comment.StartLine
1303
+ if start == nil {
1304
+ start = comment.OriginalStartLine
1305
+ }
1306
+ coord := "`" + comment.Path + "`"
1307
+ if line != nil {
1308
+ if start != nil && *start != *line {
1309
+ coord += fmt.Sprintf(" lines %d-%d", *start, *line)
1310
+ } else {
1311
+ coord += fmt.Sprintf(" line %d", *line)
1312
+ }
1313
+ }
1314
+ side := comment.Side
1315
+ if side == "" {
1316
+ side = comment.OriginalSide
1317
+ }
1318
+ if side != "" {
1319
+ coord += " " + strings.ToLower(side)
1320
+ }
1321
+ return coord
1322
+ }
1323
+
1324
+ func renderPullTimelineState(event githubTimelineEvent) (string, bool) {
1325
+ switch event.Event {
1326
+ case "commented":
1327
+ return "", false
1328
+ case "reviewed":
1329
+ // Reviews are rendered from the complete /pulls/{n}/reviews endpoint.
1330
+ return "", false
1331
+ case "mentioned", "subscribed", "unsubscribed", "copilot_work_started":
1332
+ return "", false
1333
+ }
1334
+ actor := event.Actor.Login
1335
+ if actor == "" {
1336
+ actor = event.User.Login
1337
+ }
1338
+ prefix := "- "
1339
+ if event.CreatedAt != "" {
1340
+ prefix += event.CreatedAt + " — "
1341
+ }
1342
+ if actor != "" {
1343
+ prefix += "@" + actor + " "
1344
+ }
1345
+ switch event.Event {
1346
+ case "committed":
1347
+ sha := event.SHA
1348
+ if sha == "" {
1349
+ sha = event.CommitID
1350
+ }
1351
+ detail := "added commit"
1352
+ if sha != "" {
1353
+ detail += " `" + shortSHA(sha) + "`"
1354
+ }
1355
+ if event.Message != "" {
1356
+ detail += " — " + firstLine(event.Message)
1357
+ }
1358
+ return prefix + detail + ".", true
1359
+ case "head_ref_force_pushed":
1360
+ detail := "force-pushed the head ref"
1361
+ if event.CommitID != "" {
1362
+ detail += " to `" + shortSHA(event.CommitID) + "`"
1363
+ }
1364
+ return prefix + detail + ".", true
1365
+ case "base_ref_changed":
1366
+ return prefix + "changed the base ref.", true
1367
+ case "ready_for_review":
1368
+ return prefix + "marked the Pull Request ready for review.", true
1369
+ case "review_requested", "review_request_removed":
1370
+ login := ""
1371
+ if event.RequestedReviewer != nil {
1372
+ login = event.RequestedReviewer.Login
1373
+ }
1374
+ team := ""
1375
+ if event.RequestedTeam != nil {
1376
+ team = event.RequestedTeam.Name
1377
+ if team == "" {
1378
+ team = event.RequestedTeam.Slug
1379
+ }
1380
+ }
1381
+ verb := "requested review"
1382
+ if event.Event == "review_request_removed" {
1383
+ verb = "removed review request"
1384
+ }
1385
+ if login != "" {
1386
+ return prefix + verb + " from @" + login + ".", true
1387
+ }
1388
+ if team != "" {
1389
+ return prefix + verb + " from team `" + team + "`.", true
1390
+ }
1391
+ return prefix + verb + ".", true
1392
+ case "renamed":
1393
+ if event.Rename != nil {
1394
+ return prefix + "renamed the Pull Request from `" + event.Rename.From + "` to `" + event.Rename.To + "`.", true
1395
+ }
1396
+ return prefix + "renamed the Pull Request.", true
1397
+ case "merged":
1398
+ return prefix + "merged the Pull Request.", true
1399
+ case "closed":
1400
+ return prefix + "closed the Pull Request.", true
1401
+ case "reopened":
1402
+ return prefix + "reopened the Pull Request.", true
1403
+ default:
1404
+ return renderIssueTimelineState(event)
1405
+ }
1406
+ }
1407
+
1408
+ func shortSHA(sha string) string {
1409
+ sha = strings.TrimSpace(sha)
1410
+ if len(sha) > 12 {
1411
+ return sha[:12]
1412
+ }
1413
+ return sha
1414
+ }
1415
+
1416
+ func firstLine(text string) string {
1417
+ line, _, _ := strings.Cut(strings.TrimSpace(text), "\n")
1418
+ return line
1419
+ }
1420
+
1421
+ func renderGitHubPullThreadSelector(target *GitHubTarget, thread githubPullThread, selectedID int64) string {
1422
+ lines := []string{
1423
+ "---",
1424
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
1425
+ fmt.Sprintf("pull_request: %d", target.Number),
1426
+ fmt.Sprintf("selected_review_comment: %d", selectedID),
1427
+ "---",
1428
+ "",
1429
+ fmt.Sprintf("# Review thread on %s/%s#%d", target.Owner, target.Repo, target.Number),
1430
+ "",
1431
+ }
1432
+ lines = append(lines, renderPullThread(thread, selectedID)...)
1433
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1434
+ }
1435
+
1436
+ func renderGitHubPullReviewSelector(target *GitHubTarget, review githubPullReview, threads []githubPullThread) string {
1437
+ lines := []string{
1438
+ "---",
1439
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
1440
+ fmt.Sprintf("pull_request: %d", target.Number),
1441
+ fmt.Sprintf("review_id: %d", review.ID),
1442
+ "state: " + yamlScalar(review.State),
1443
+ }
1444
+ if review.User.Login != "" {
1445
+ lines = append(lines, "reviewer: "+yamlScalar("@"+review.User.Login))
1446
+ }
1447
+ if review.SubmittedAt != "" {
1448
+ lines = append(lines, "submitted: "+yamlScalar(review.SubmittedAt))
1449
+ }
1450
+ lines = append(lines, "---", "", fmt.Sprintf("# Review on %s/%s#%d", target.Owner, target.Repo, target.Number), "")
1451
+ lines = append(lines, renderPullReview(review)...)
1452
+ if len(threads) > 0 {
1453
+ lines = append(lines, "## Review comments", "")
1454
+ for _, thread := range threads {
1455
+ lines = append(lines, renderPullThread(thread, 0)...)
1456
+ }
1457
+ }
1458
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1459
+ }