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,1141 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "fmt"
9
+ "net/http"
10
+ "net/url"
11
+ "regexp"
12
+ "sort"
13
+ "strconv"
14
+ "strings"
15
+ )
16
+
17
+ const githubPullFilesMax = 3000
18
+
19
+ type githubPullFile struct {
20
+ SHA string `json:"sha"`
21
+ Filename string `json:"filename"`
22
+ Status string `json:"status"`
23
+ Additions int `json:"additions"`
24
+ Deletions int `json:"deletions"`
25
+ Changes int `json:"changes"`
26
+ BlobURL string `json:"blob_url"`
27
+ RawURL string `json:"raw_url"`
28
+ ContentsURL string `json:"contents_url"`
29
+ Patch *string `json:"patch"`
30
+ PreviousFilename string `json:"previous_filename"`
31
+ }
32
+
33
+ type githubPullCommit struct {
34
+ SHA string `json:"sha"`
35
+ HTMLURL string `json:"html_url"`
36
+ Author githubUser `json:"author"`
37
+ Commit struct {
38
+ Message string `json:"message"`
39
+ Author struct {
40
+ Name string `json:"name"`
41
+ Email string `json:"email"`
42
+ Date string `json:"date"`
43
+ } `json:"author"`
44
+ Committer struct {
45
+ Name string `json:"name"`
46
+ Date string `json:"date"`
47
+ } `json:"committer"`
48
+ } `json:"commit"`
49
+ }
50
+
51
+ type githubCheckRun struct {
52
+ ID int64 `json:"id"`
53
+ Name string `json:"name"`
54
+ HeadSHA string `json:"head_sha"`
55
+ Status string `json:"status"`
56
+ Conclusion string `json:"conclusion"`
57
+ StartedAt string `json:"started_at"`
58
+ CompletedAt string `json:"completed_at"`
59
+ DetailsURL string `json:"details_url"`
60
+ HTMLURL string `json:"html_url"`
61
+ ExternalID string `json:"external_id"`
62
+ App *struct {
63
+ Slug string `json:"slug"`
64
+ Name string `json:"name"`
65
+ } `json:"app"`
66
+ Output struct {
67
+ Title string `json:"title"`
68
+ Summary string `json:"summary"`
69
+ Text string `json:"text"`
70
+ AnnotationsCount int `json:"annotations_count"`
71
+ AnnotationsURL string `json:"annotations_url"`
72
+ } `json:"output"`
73
+ }
74
+
75
+ type githubCheckRunsPage struct {
76
+ TotalCount int `json:"total_count"`
77
+ CheckRuns []githubCheckRun `json:"check_runs"`
78
+ }
79
+
80
+ type githubCommitStatus struct {
81
+ ID int64 `json:"id"`
82
+ State string `json:"state"`
83
+ Description string `json:"description"`
84
+ Context string `json:"context"`
85
+ TargetURL string `json:"target_url"`
86
+ CreatedAt string `json:"created_at"`
87
+ UpdatedAt string `json:"updated_at"`
88
+ Creator githubUser `json:"creator"`
89
+ }
90
+
91
+ type githubCombinedStatus struct {
92
+ State string `json:"state"`
93
+ SHA string `json:"sha"`
94
+ TotalCount int `json:"total_count"`
95
+ Statuses []githubCommitStatus `json:"statuses"`
96
+ }
97
+
98
+ type githubCheckAnnotation struct {
99
+ Path string `json:"path"`
100
+ StartLine int `json:"start_line"`
101
+ EndLine int `json:"end_line"`
102
+ StartColumn *int `json:"start_column"`
103
+ EndColumn *int `json:"end_column"`
104
+ AnnotationLevel string `json:"annotation_level"`
105
+ Message string `json:"message"`
106
+ Title string `json:"title"`
107
+ RawDetails string `json:"raw_details"`
108
+ BlobHref string `json:"blob_href"`
109
+ }
110
+
111
+ type githubDiffSelector struct {
112
+ Hash string
113
+ Side byte
114
+ Start int
115
+ End int
116
+ }
117
+
118
+ type githubPullFilesAvailability struct {
119
+ ProviderReturned int
120
+ ProviderMore bool
121
+ ProviderComplete bool
122
+ ProviderCapReached bool
123
+ }
124
+
125
+ var (
126
+ githubDiffSelectorRE = regexp.MustCompile(`^diff-([0-9a-fA-F]{64})(?:([LR])([0-9]+)(?:-([LR])([0-9]+))?)?$`)
127
+ githubDiffHunkRE = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)
128
+ )
129
+
130
+ func readGitHubPullFiles(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
131
+ selector, hasSelector, err := parseGitHubDiffSelector(target.Fragment)
132
+ if err != nil {
133
+ return "", err
134
+ }
135
+ pr, err := fetchGitHubPullIdentity(ctx, client, target)
136
+ if err != nil {
137
+ return "", err
138
+ }
139
+ var (
140
+ files []githubPullFile
141
+ providerMore bool
142
+ )
143
+ if hasSelector {
144
+ files, err = fetchGitHubPullFiles(ctx, client, target)
145
+ } else {
146
+ files, providerMore, err = fetchGitHubPullFilesPage(ctx, client, target)
147
+ }
148
+ if err != nil {
149
+ return "", err
150
+ }
151
+ availability := githubPullFilesAvailability{
152
+ ProviderReturned: len(files),
153
+ ProviderMore: providerMore,
154
+ ProviderCapReached: pr.ChangedFiles >= githubPullFilesMax || len(files) >= githubPullFilesMax,
155
+ }
156
+ availability.ProviderComplete = !availability.ProviderMore && !availability.ProviderCapReached && pr.ChangedFiles <= len(files)
157
+
158
+ selected := files
159
+ if hasSelector {
160
+ var file *githubPullFile
161
+ for i := range files {
162
+ if githubDiffPathHash(files[i].Filename) == selector.Hash {
163
+ file = &files[i]
164
+ break
165
+ }
166
+ }
167
+ if file == nil {
168
+ return "", fmt.Errorf("GitHub diff selector %q does not match any changed file in this Pull Request", target.Fragment)
169
+ }
170
+ copyFile := *file
171
+ if selector.Side != 0 {
172
+ if copyFile.Patch == nil || strings.TrimSpace(*copyFile.Patch) == "" {
173
+ return "", fmt.Errorf("GitHub diff line selector %q targets a file whose patch is unavailable from the provider", target.Fragment)
174
+ }
175
+ patch, err := selectGitHubPatchHunks(*copyFile.Patch, selector.Side, selector.Start, selector.End)
176
+ if err != nil {
177
+ return "", fmt.Errorf("GitHub diff line selector %q is stale or out of range: %w", target.Fragment, err)
178
+ }
179
+ copyFile.Patch = &patch
180
+ }
181
+ selected = []githubPullFile{copyFile}
182
+ }
183
+
184
+ return renderGitHubPullFiles(target, pr, selected, availability, selector, hasSelector), nil
185
+ }
186
+
187
+ func fetchGitHubPullIdentity(ctx context.Context, client *GitHubClient, target *GitHubTarget) (githubPullRequest, error) {
188
+ endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
189
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
190
+ if err != nil {
191
+ return githubPullRequest{}, err
192
+ }
193
+ var pr githubPullRequest
194
+ if err := json.Unmarshal(resp.Body, &pr); err != nil {
195
+ return githubPullRequest{}, fmt.Errorf("decode GitHub Pull Request: %w", err)
196
+ }
197
+ return pr, nil
198
+ }
199
+
200
+ func fetchGitHubPullFiles(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubPullFile, error) {
201
+ endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/files?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
202
+ pages, err := client.RESTPages(ctx, endpoint, "application/vnd.github+json")
203
+ if err != nil {
204
+ return nil, err
205
+ }
206
+ files := []githubPullFile{}
207
+ for _, page := range pages {
208
+ var batch []githubPullFile
209
+ if err := json.Unmarshal(page.Body, &batch); err != nil {
210
+ return nil, fmt.Errorf("decode GitHub Pull Request files: %w", err)
211
+ }
212
+ files = append(files, batch...)
213
+ }
214
+ return files, nil
215
+ }
216
+
217
+ func fetchGitHubPullFilesPage(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubPullFile, bool, error) {
218
+ endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/files?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
219
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
220
+ if err != nil {
221
+ return nil, false, err
222
+ }
223
+ var files []githubPullFile
224
+ if err := json.Unmarshal(resp.Body, &files); err != nil {
225
+ return nil, false, fmt.Errorf("decode GitHub Pull Request files: %w", err)
226
+ }
227
+ return files, strings.TrimSpace(resp.Links()["next"]) != "", nil
228
+ }
229
+
230
+ func parseGitHubDiffSelector(fragment string) (githubDiffSelector, bool, error) {
231
+ if fragment == "" {
232
+ return githubDiffSelector{}, false, nil
233
+ }
234
+ if !strings.HasPrefix(fragment, "diff-") {
235
+ return githubDiffSelector{}, true, fmt.Errorf("GitHub Files Changed fragment %q is not a supported diff selector", fragment)
236
+ }
237
+ match := githubDiffSelectorRE.FindStringSubmatch(fragment)
238
+ if match == nil {
239
+ return githubDiffSelector{}, true, fmt.Errorf("invalid GitHub diff selector %q", fragment)
240
+ }
241
+ selector := githubDiffSelector{Hash: strings.ToLower(match[1])}
242
+ if match[2] == "" {
243
+ return selector, true, nil
244
+ }
245
+ selector.Side = match[2][0]
246
+ selector.Start, _ = strconv.Atoi(match[3])
247
+ selector.End = selector.Start
248
+ if match[4] != "" {
249
+ if match[4][0] != selector.Side {
250
+ return githubDiffSelector{}, true, fmt.Errorf("GitHub diff selector %q mixes left and right sides", fragment)
251
+ }
252
+ selector.End, _ = strconv.Atoi(match[5])
253
+ if selector.End < selector.Start {
254
+ return githubDiffSelector{}, true, fmt.Errorf("GitHub diff selector %q has a reversed line range", fragment)
255
+ }
256
+ }
257
+ if selector.Start <= 0 || selector.End <= 0 {
258
+ return githubDiffSelector{}, true, fmt.Errorf("GitHub diff selector %q has an invalid line number", fragment)
259
+ }
260
+ return selector, true, nil
261
+ }
262
+
263
+ func githubDiffPathHash(filename string) string {
264
+ sum := sha256.Sum256([]byte(filename))
265
+ return hex.EncodeToString(sum[:])
266
+ }
267
+
268
+ func selectGitHubPatchHunks(patch string, side byte, start, end int) (string, error) {
269
+ lines := strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n")
270
+ type hunk struct {
271
+ lines []string
272
+ matches bool
273
+ }
274
+ hunks := []hunk{}
275
+ current := -1
276
+ oldLine, newLine := 0, 0
277
+ for _, line := range lines {
278
+ if match := githubDiffHunkRE.FindStringSubmatch(line); match != nil {
279
+ oldLine, _ = strconv.Atoi(match[1])
280
+ newLine, _ = strconv.Atoi(match[3])
281
+ hunks = append(hunks, hunk{lines: []string{line}})
282
+ current = len(hunks) - 1
283
+ continue
284
+ }
285
+ if current < 0 {
286
+ continue
287
+ }
288
+ hunks[current].lines = append(hunks[current].lines, line)
289
+ if line == "" {
290
+ // An empty context line in a unified diff is represented with a
291
+ // leading space. A truly empty line here is provider formatting noise.
292
+ continue
293
+ }
294
+ var oldAt, newAt int
295
+ switch line[0] {
296
+ case ' ':
297
+ oldAt, newAt = oldLine, newLine
298
+ oldLine++
299
+ newLine++
300
+ case '-':
301
+ oldAt = oldLine
302
+ oldLine++
303
+ case '+':
304
+ newAt = newLine
305
+ newLine++
306
+ case '\\':
307
+ continue
308
+ default:
309
+ continue
310
+ }
311
+ at := newAt
312
+ if side == 'L' {
313
+ at = oldAt
314
+ }
315
+ if at >= start && at <= end {
316
+ hunks[current].matches = true
317
+ }
318
+ }
319
+ selected := []string{}
320
+ for _, h := range hunks {
321
+ if h.matches {
322
+ selected = append(selected, strings.Join(h.lines, "\n"))
323
+ }
324
+ }
325
+ if len(selected) == 0 {
326
+ return "", fmt.Errorf("selected %c%d-%d line is not present in the provider patch", side, start, end)
327
+ }
328
+ return strings.Join(selected, "\n"), nil
329
+ }
330
+
331
+ func renderGitHubPullFiles(target *GitHubTarget, pr githubPullRequest, files []githubPullFile, availability githubPullFilesAvailability, selector githubDiffSelector, hasSelector bool) string {
332
+ if hasSelector {
333
+ return renderGitHubPullFilesFull(target, pr, files, availability, selector, true)
334
+ }
335
+ if availability.ProviderComplete {
336
+ full := renderGitHubPullFilesFull(target, pr, files, availability, selector, false)
337
+ if githubOverviewFits(full) {
338
+ return full
339
+ }
340
+ }
341
+ return renderGitHubPullFilesOverview(target, pr, files, availability)
342
+ }
343
+
344
+ func renderGitHubPullFilesFull(target *GitHubTarget, pr githubPullRequest, files []githubPullFile, availability githubPullFilesAvailability, selector githubDiffSelector, hasSelector bool) string {
345
+ lines := []string{
346
+ "---",
347
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
348
+ fmt.Sprintf("pull_request: %d", target.Number),
349
+ "view: files",
350
+ fmt.Sprintf("files_returned: %d", availability.ProviderReturned),
351
+ fmt.Sprintf("files_rendered: %d", len(files)),
352
+ fmt.Sprintf("changed_files: %d", pr.ChangedFiles),
353
+ fmt.Sprintf("complete: %t", availability.ProviderComplete),
354
+ }
355
+ if hasSelector {
356
+ lines = append(lines, "selector: "+yamlScalar(target.Fragment))
357
+ }
358
+ lines = append(lines, "---", "", fmt.Sprintf("# Files changed for %s/%s#%d", target.Owner, target.Repo, target.Number), "")
359
+ if availability.ProviderCapReached {
360
+ lines = append(lines, "> GitHub's Pull Request files API has a 3,000-file maximum. This result must not be treated as complete beyond that provider ceiling.", "")
361
+ }
362
+ if len(files) == 0 {
363
+ lines = append(lines, "_No changed files returned by GitHub._")
364
+ }
365
+ for _, file := range files {
366
+ lines = append(lines, renderGitHubPullFile(file, selector, hasSelector)...)
367
+ }
368
+ lines = append(lines, "", "## Useful GitHub URLs", "")
369
+ lines = append(lines, pullViewHints(target, "files")...)
370
+ return strings.TrimSpace(strings.Join(lines, "\n"))
371
+ }
372
+
373
+ func renderGitHubPullFilesOverview(target *GitHubTarget, pr githubPullRequest, files []githubPullFile, availability githubPullFilesAvailability) string {
374
+ indexLimit := minInt(18, len(files))
375
+ patchPreviewCount := minInt(2, indexLimit)
376
+ patchPreviewRunes := 180
377
+ for {
378
+ out := renderGitHubPullFilesOverviewWithLimits(target, pr, files, availability, indexLimit, patchPreviewCount, patchPreviewRunes)
379
+ if githubOverviewFits(out) {
380
+ return out
381
+ }
382
+ switch {
383
+ case patchPreviewCount > 0:
384
+ patchPreviewCount--
385
+ case indexLimit > 1:
386
+ indexLimit--
387
+ case patchPreviewRunes > 100:
388
+ patchPreviewRunes = 100
389
+ default:
390
+ return out
391
+ }
392
+ }
393
+ }
394
+
395
+ func renderGitHubPullFilesOverviewWithLimits(target *GitHubTarget, pr githubPullRequest, files []githubPullFile, availability githubPullFilesAvailability, indexLimit, patchPreviewCount, patchPreviewRunes int) string {
396
+ indexLimit = minInt(indexLimit, len(files))
397
+ patchPreviewCount = minInt(patchPreviewCount, indexLimit)
398
+ lines := []string{
399
+ "---",
400
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
401
+ fmt.Sprintf("pull_request: %d", target.Number),
402
+ "view: files",
403
+ "overview: true",
404
+ fmt.Sprintf("changed_files: %d", pr.ChangedFiles),
405
+ fmt.Sprintf("files_returned: %d", availability.ProviderReturned),
406
+ fmt.Sprintf("files_indexed: %d", indexLimit),
407
+ fmt.Sprintf("files_local_omitted: %d", len(files)-indexLimit),
408
+ fmt.Sprintf("complete: %t", availability.ProviderComplete),
409
+ }
410
+ if availability.ProviderMore {
411
+ lines = append(lines, "provider_more_available: true")
412
+ }
413
+ if availability.ProviderCapReached {
414
+ lines = append(lines, fmt.Sprintf("provider_result_ceiling: %d", githubPullFilesMax))
415
+ }
416
+ lines = append(lines, "---", "", fmt.Sprintf("# Files changed for %s/%s#%d", target.Owner, target.Repo, target.Number), "", "> Large Files overview: changed files are indexed with exact GitHub diff selectors instead of expanding every patch.")
417
+ if availability.ProviderCapReached {
418
+ lines = append(lines, "", "> GitHub's Pull Request files API has a 3,000-file maximum. Provider ceiling and local overview omission are reported separately.")
419
+ }
420
+ if availability.ProviderMore {
421
+ lines = append(lines, "", "> GitHub has more changed files beyond the provider page fetched for this overview.")
422
+ } else if !availability.ProviderComplete && pr.ChangedFiles > availability.ProviderReturned {
423
+ lines = append(lines, "", "> GitHub reports more changed files than were returned by the fetched provider data; this provider-incomplete state is separate from local overview omission.")
424
+ }
425
+ if len(files) == 0 {
426
+ lines = append(lines, "", "_No changed files returned by GitHub._")
427
+ }
428
+ for i, file := range files[:indexLimit] {
429
+ lines = append(lines, "", "### `"+file.Filename+"`", "")
430
+ meta := []string{}
431
+ if file.Status != "" {
432
+ meta = append(meta, "status "+file.Status)
433
+ }
434
+ meta = append(meta, fmt.Sprintf("+%d", file.Additions), fmt.Sprintf("-%d", file.Deletions), fmt.Sprintf("%d changes", file.Changes))
435
+ if file.PreviousFilename != "" {
436
+ meta = append(meta, "renamed from `"+file.PreviousFilename+"`")
437
+ }
438
+ lines = append(lines, "_"+strings.Join(meta, " · ")+"_")
439
+ lines = append(lines, "Selector: "+pullFileSelectorURL(target, file.Filename))
440
+ if file.BlobURL != "" {
441
+ lines = append(lines, "Blob: "+file.BlobURL)
442
+ }
443
+ if file.RawURL != "" {
444
+ lines = append(lines, "Raw: "+file.RawURL)
445
+ }
446
+ if i < patchPreviewCount {
447
+ if file.Patch == nil || strings.TrimSpace(*file.Patch) == "" {
448
+ lines = append(lines, "Patch: _unavailable from GitHub._")
449
+ } else {
450
+ preview, truncated := githubOverviewPreview(*file.Patch, patchPreviewRunes)
451
+ lines = append(lines, "", "Patch preview:", "", "```diff", preview, "```")
452
+ if truncated {
453
+ lines = append(lines, "> Patch preview locally truncated; use the exact diff selector above for the full provider patch.")
454
+ }
455
+ }
456
+ }
457
+ }
458
+ if note := githubLocalOmissionNote("changed files returned on this provider page", len(files)-indexLimit); note != "" {
459
+ lines = append(lines, "", note)
460
+ }
461
+ lines = append(lines, "", "## Useful GitHub URLs", "")
462
+ lines = append(lines, pullViewHints(target, "files")...)
463
+ return strings.TrimSpace(strings.Join(lines, "\n"))
464
+ }
465
+
466
+ func pullFileSelectorURL(target *GitHubTarget, filename string) string {
467
+ base := fmt.Sprintf("https://github.com/%s/%s/pull/%d/files", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), target.Number)
468
+ return base + "#diff-" + githubDiffPathHash(filename)
469
+ }
470
+
471
+ func renderGitHubPullFile(file githubPullFile, selector githubDiffSelector, hasSelector bool) []string {
472
+ lines := []string{"## " + file.Filename, ""}
473
+ meta := []string{}
474
+ if file.Status != "" {
475
+ meta = append(meta, "status "+file.Status)
476
+ }
477
+ meta = append(meta, fmt.Sprintf("+%d", file.Additions), fmt.Sprintf("-%d", file.Deletions), fmt.Sprintf("%d changes", file.Changes))
478
+ if file.PreviousFilename != "" {
479
+ meta = append(meta, "renamed from `"+file.PreviousFilename+"`")
480
+ }
481
+ lines = append(lines, "_"+strings.Join(meta, " · ")+"_", "")
482
+ if file.BlobURL != "" {
483
+ lines = append(lines, "Blob: "+file.BlobURL)
484
+ }
485
+ if file.RawURL != "" {
486
+ lines = append(lines, "Raw: "+file.RawURL)
487
+ }
488
+ if file.BlobURL != "" || file.RawURL != "" {
489
+ lines = append(lines, "")
490
+ }
491
+ if file.Patch == nil || strings.TrimSpace(*file.Patch) == "" {
492
+ lines = append(lines, "_Patch unavailable from GitHub (for example binary, oversized, or provider-omitted content)._", "")
493
+ return lines
494
+ }
495
+ if hasSelector && selector.Side != 0 {
496
+ label := fmt.Sprintf("Selected %c%d", selector.Side, selector.Start)
497
+ if selector.End != selector.Start {
498
+ label += fmt.Sprintf("-%c%d", selector.Side, selector.End)
499
+ }
500
+ lines = append(lines, "**"+label+" — matching diff hunk:**", "")
501
+ }
502
+ lines = append(lines, "```diff", *file.Patch, "```", "")
503
+ return lines
504
+ }
505
+
506
+ func readGitHubPullCommits(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
507
+ if target.Fragment != "" {
508
+ return "", fmt.Errorf("GitHub Pull Request commits fragment %q is not a supported native selector", target.Fragment)
509
+ }
510
+ pr, err := fetchGitHubPullIdentity(ctx, client, target)
511
+ if err != nil {
512
+ return "", err
513
+ }
514
+ endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/commits?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
515
+ commits := []githubPullCommit{}
516
+ next := endpoint
517
+ providerMore := false
518
+ for pageNumber := 0; pageNumber < 3 && strings.TrimSpace(next) != ""; pageNumber++ {
519
+ page, err := client.REST(ctx, http.MethodGet, next, "application/vnd.github+json")
520
+ if err != nil {
521
+ return "", err
522
+ }
523
+ var batch []githubPullCommit
524
+ if err := json.Unmarshal(page.Body, &batch); err != nil {
525
+ return "", fmt.Errorf("decode GitHub Pull Request commits: %w", err)
526
+ }
527
+ remaining := 250 - len(commits)
528
+ if remaining <= 0 {
529
+ break
530
+ }
531
+ if len(batch) > remaining {
532
+ batch = batch[:remaining]
533
+ }
534
+ commits = append(commits, batch...)
535
+ next = page.Links()["next"]
536
+ providerMore = strings.TrimSpace(next) != ""
537
+ if len(commits) >= 250 {
538
+ break
539
+ }
540
+ }
541
+ return renderGitHubPullCommits(target, pr, commits, providerMore), nil
542
+ }
543
+
544
+ func renderGitHubPullCommits(target *GitHubTarget, pr githubPullRequest, commits []githubPullCommit, providerMore bool) string {
545
+ limit := minInt(30, len(commits))
546
+ messageRunes := 180
547
+ for {
548
+ out := renderGitHubPullCommitsWithLimits(target, pr, commits, providerMore, limit, messageRunes)
549
+ if githubOverviewFits(out) || limit <= 1 {
550
+ return out
551
+ }
552
+ switch {
553
+ case limit > 8:
554
+ limit--
555
+ case messageRunes > 100:
556
+ messageRunes -= 20
557
+ case limit > 1:
558
+ limit--
559
+ default:
560
+ return out
561
+ }
562
+ }
563
+ }
564
+
565
+ func renderGitHubPullCommitsWithLimits(target *GitHubTarget, pr githubPullRequest, commits []githubPullCommit, providerMore bool, limit, messageRunes int) string {
566
+ limit = minInt(limit, len(commits))
567
+ lines := []string{
568
+ "---",
569
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
570
+ fmt.Sprintf("pull_request: %d", target.Number),
571
+ "view: commits",
572
+ fmt.Sprintf("commits_returned: %d", len(commits)),
573
+ fmt.Sprintf("commits_reported: %d", pr.Commits),
574
+ fmt.Sprintf("commits_indexed: %d", limit),
575
+ fmt.Sprintf("commits_local_omitted: %d", len(commits)-limit),
576
+ }
577
+ if pr.Commits > 250 {
578
+ lines = append(lines, "provider_result_ceiling: 250", "provider_complete: false")
579
+ }
580
+ if providerMore {
581
+ lines = append(lines, "provider_more_available: true", "provider_complete: false")
582
+ }
583
+ lines = append(lines,
584
+ "---",
585
+ "",
586
+ fmt.Sprintf("# Commits for %s/%s#%d", target.Owner, target.Repo, target.Number),
587
+ "",
588
+ )
589
+ if pr.Commits > 250 {
590
+ lines = append(lines, "> GitHub's Pull Request commits endpoint exposes at most 250 commits. The Pull Request reports more commits than this endpoint can return; use repository commit history or the individual commit URLs below for deeper navigation.", "")
591
+ }
592
+ if providerMore {
593
+ lines = append(lines, "> GitHub reported another Pull Request commit page beyond the bounded three-page provider ceiling used by this view.", "")
594
+ }
595
+ if len(commits) == 0 {
596
+ lines = append(lines, "_No commits returned by GitHub._")
597
+ }
598
+ for _, commit := range commits[:limit] {
599
+ message, truncated := githubOverviewPreview(firstLine(commit.Commit.Message), messageRunes)
600
+ message = strings.ReplaceAll(message, "\n", " ")
601
+ label := "`" + shortSHA(commit.SHA) + "`"
602
+ if commit.HTMLURL != "" {
603
+ label = "[" + label + "](" + commit.HTMLURL + ")"
604
+ }
605
+ meta := []string{}
606
+ if commit.Author.Login != "" {
607
+ meta = append(meta, "@"+commit.Author.Login)
608
+ } else if commit.Commit.Author.Name != "" {
609
+ meta = append(meta, commit.Commit.Author.Name)
610
+ }
611
+ if commit.Commit.Author.Date != "" {
612
+ meta = append(meta, commit.Commit.Author.Date)
613
+ }
614
+ line := "- " + label
615
+ if message != "" {
616
+ line += " " + message
617
+ if truncated {
618
+ line += "…"
619
+ }
620
+ }
621
+ if len(meta) > 0 {
622
+ line += " — " + strings.Join(meta, " · ")
623
+ }
624
+ lines = append(lines, line)
625
+ }
626
+ if note := githubLocalOmissionNote("Pull Request commits returned by GitHub", len(commits)-limit); note != "" {
627
+ lines = append(lines, "", note)
628
+ }
629
+ lines = append(lines, "", "## Useful GitHub URLs", "")
630
+ lines = append(lines, pullViewHints(target, "commits")...)
631
+ return strings.TrimSpace(strings.Join(lines, "\n"))
632
+ }
633
+
634
+ func readGitHubPullChecks(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
635
+ if target.Fragment != "" {
636
+ return "", fmt.Errorf("GitHub Pull Request checks fragment %q is not a supported native selector", target.Fragment)
637
+ }
638
+ pr, err := fetchGitHubPullIdentity(ctx, client, target)
639
+ if err != nil {
640
+ return "", err
641
+ }
642
+ if pr.Head.SHA == "" {
643
+ return "", fmt.Errorf("GitHub Pull Request did not report a head commit SHA for checks")
644
+ }
645
+ if rawID := strings.TrimSpace(target.Query.Get("check_run_id")); rawID != "" {
646
+ id, err := strconv.ParseInt(rawID, 10, 64)
647
+ if err != nil || id <= 0 {
648
+ return "", fmt.Errorf("invalid GitHub check_run_id %q", rawID)
649
+ }
650
+ return readGitHubSelectedCheck(ctx, client, target, pr, id)
651
+ }
652
+
653
+ runs, total, err := fetchGitHubCheckRuns(ctx, client, target, pr.Head.SHA)
654
+ if err != nil {
655
+ return "", err
656
+ }
657
+ status, err := fetchGitHubCombinedStatus(ctx, client, target, pr.Head.SHA)
658
+ if err != nil {
659
+ return "", err
660
+ }
661
+ return renderGitHubPullChecks(target, pr, runs, total, status), nil
662
+ }
663
+
664
+ func fetchGitHubCheckRuns(ctx context.Context, client *GitHubClient, target *GitHubTarget, sha string) ([]githubCheckRun, int, error) {
665
+ endpoint := fmt.Sprintf("/repos/%s/%s/commits/%s/check-runs?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), url.PathEscape(sha))
666
+ pages, err := client.RESTPages(ctx, endpoint, "application/vnd.github+json")
667
+ if err != nil {
668
+ return nil, 0, err
669
+ }
670
+ runs := []githubCheckRun{}
671
+ total := 0
672
+ for _, page := range pages {
673
+ var batch githubCheckRunsPage
674
+ if err := json.Unmarshal(page.Body, &batch); err != nil {
675
+ return nil, 0, fmt.Errorf("decode GitHub check runs: %w", err)
676
+ }
677
+ if batch.TotalCount > total {
678
+ total = batch.TotalCount
679
+ }
680
+ runs = append(runs, batch.CheckRuns...)
681
+ }
682
+ sortGitHubCheckRuns(runs)
683
+ return runs, total, nil
684
+ }
685
+
686
+ func sortGitHubCheckRuns(runs []githubCheckRun) {
687
+ sort.SliceStable(runs, func(i, j int) bool {
688
+ pi, pj := githubCheckRunPriority(runs[i]), githubCheckRunPriority(runs[j])
689
+ if pi != pj {
690
+ return pi < pj
691
+ }
692
+ if runs[i].Name == runs[j].Name {
693
+ return runs[i].ID < runs[j].ID
694
+ }
695
+ return runs[i].Name < runs[j].Name
696
+ })
697
+ }
698
+
699
+ func githubCheckRunPriority(run githubCheckRun) int {
700
+ conclusion := strings.ToLower(strings.TrimSpace(run.Conclusion))
701
+ status := strings.ToLower(strings.TrimSpace(run.Status))
702
+ switch conclusion {
703
+ case "failure", "error", "cancelled", "canceled", "timed_out", "action_required", "startup_failure":
704
+ return 0
705
+ }
706
+ if status != "" && status != "completed" {
707
+ return 1
708
+ }
709
+ if conclusion == "" {
710
+ return 1
711
+ }
712
+ if conclusion == "success" || conclusion == "neutral" || conclusion == "skipped" || conclusion == "stale" {
713
+ return 2
714
+ }
715
+ return 0
716
+ }
717
+
718
+ func fetchGitHubCombinedStatus(ctx context.Context, client *GitHubClient, target *GitHubTarget, sha string) (githubCombinedStatus, error) {
719
+ endpoint := fmt.Sprintf("/repos/%s/%s/commits/%s/status?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), url.PathEscape(sha))
720
+ pages, err := client.RESTPages(ctx, endpoint, "application/vnd.github+json")
721
+ if err != nil {
722
+ return githubCombinedStatus{}, err
723
+ }
724
+ combined := githubCombinedStatus{}
725
+ for _, page := range pages {
726
+ var status githubCombinedStatus
727
+ if err := json.Unmarshal(page.Body, &status); err != nil {
728
+ return githubCombinedStatus{}, fmt.Errorf("decode GitHub commit status: %w", err)
729
+ }
730
+ if combined.State == "" {
731
+ combined.State = status.State
732
+ }
733
+ if combined.SHA == "" {
734
+ combined.SHA = status.SHA
735
+ }
736
+ if status.TotalCount > combined.TotalCount {
737
+ combined.TotalCount = status.TotalCount
738
+ }
739
+ combined.Statuses = append(combined.Statuses, status.Statuses...)
740
+ }
741
+ return combined, nil
742
+ }
743
+
744
+ func readGitHubSelectedCheck(ctx context.Context, client *GitHubClient, target *GitHubTarget, pr githubPullRequest, id int64) (string, error) {
745
+ endpoint := fmt.Sprintf("/repos/%s/%s/check-runs/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), id)
746
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
747
+ if err != nil {
748
+ return "", err
749
+ }
750
+ var run githubCheckRun
751
+ if err := json.Unmarshal(resp.Body, &run); err != nil {
752
+ return "", fmt.Errorf("decode GitHub check run: %w", err)
753
+ }
754
+ if run.HeadSHA != "" && !strings.EqualFold(run.HeadSHA, pr.Head.SHA) {
755
+ return "", fmt.Errorf("GitHub check run %d belongs to head %s, not Pull Request head %s", id, shortSHA(run.HeadSHA), shortSHA(pr.Head.SHA))
756
+ }
757
+ annotations, err := fetchGitHubCheckAnnotations(ctx, client, target, id)
758
+ if err != nil {
759
+ return "", err
760
+ }
761
+ return renderGitHubSelectedCheck(target, pr, run, annotations), nil
762
+ }
763
+
764
+ func fetchGitHubCheckAnnotations(ctx context.Context, client *GitHubClient, target *GitHubTarget, id int64) ([]githubCheckAnnotation, error) {
765
+ endpoint := fmt.Sprintf("/repos/%s/%s/check-runs/%d/annotations?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), id)
766
+ pages, err := client.RESTPages(ctx, endpoint, "application/vnd.github+json")
767
+ if err != nil {
768
+ return nil, err
769
+ }
770
+ annotations := []githubCheckAnnotation{}
771
+ for _, page := range pages {
772
+ var batch []githubCheckAnnotation
773
+ if err := json.Unmarshal(page.Body, &batch); err != nil {
774
+ return nil, fmt.Errorf("decode GitHub check annotations: %w", err)
775
+ }
776
+ annotations = append(annotations, batch...)
777
+ }
778
+ return annotations, nil
779
+ }
780
+
781
+ func renderGitHubPullChecks(target *GitHubTarget, pr githubPullRequest, runs []githubCheckRun, total int, status githubCombinedStatus) string {
782
+ runLimit := minInt(20, len(runs))
783
+ statusLimit := minInt(10, len(status.Statuses))
784
+ for {
785
+ out := renderGitHubPullChecksWithLimits(target, pr, runs, total, status, runLimit, statusLimit)
786
+ if githubOverviewFits(out) {
787
+ return out
788
+ }
789
+ switch {
790
+ case statusLimit > 1:
791
+ statusLimit--
792
+ case runLimit > 1:
793
+ runLimit--
794
+ default:
795
+ return out
796
+ }
797
+ }
798
+ }
799
+
800
+ func renderGitHubPullChecksWithLimits(target *GitHubTarget, pr githubPullRequest, runs []githubCheckRun, total int, status githubCombinedStatus, runLimit, statusLimit int) string {
801
+ orderedRuns := append([]githubCheckRun(nil), runs...)
802
+ sortGitHubCheckRuns(orderedRuns)
803
+ runs = orderedRuns
804
+ runLimit = minInt(runLimit, len(runs))
805
+ statuses := append([]githubCommitStatus(nil), status.Statuses...)
806
+ sort.SliceStable(statuses, func(i, j int) bool {
807
+ pi, pj := githubCommitStatusPriority(statuses[i]), githubCommitStatusPriority(statuses[j])
808
+ if pi != pj {
809
+ return pi < pj
810
+ }
811
+ if statuses[i].Context == statuses[j].Context {
812
+ return statuses[i].ID < statuses[j].ID
813
+ }
814
+ return statuses[i].Context < statuses[j].Context
815
+ })
816
+ statusLimit = minInt(statusLimit, len(statuses))
817
+ statusCounts, conclusionCounts := githubCheckRunCounts(runs)
818
+ lines := []string{
819
+ "---",
820
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
821
+ fmt.Sprintf("pull_request: %d", target.Number),
822
+ "view: checks",
823
+ "head_sha: " + yamlScalar(pr.Head.SHA),
824
+ fmt.Sprintf("check_runs_returned: %d", len(runs)),
825
+ fmt.Sprintf("check_runs_reported: %d", total),
826
+ fmt.Sprintf("check_runs_indexed: %d", runLimit),
827
+ fmt.Sprintf("check_runs_local_omitted: %d", len(runs)-runLimit),
828
+ "check_run_status_counts: " + jsonMapScalar(statusCounts),
829
+ "check_run_conclusion_counts: " + jsonMapScalar(conclusionCounts),
830
+ fmt.Sprintf("commit_statuses_returned: %d", len(statuses)),
831
+ fmt.Sprintf("commit_statuses_indexed: %d", statusLimit),
832
+ fmt.Sprintf("commit_statuses_local_omitted: %d", len(statuses)-statusLimit),
833
+ "---",
834
+ "",
835
+ fmt.Sprintf("# Checks for %s/%s#%d", target.Owner, target.Repo, target.Number),
836
+ "",
837
+ "## Rollup",
838
+ "",
839
+ }
840
+ lines = append(lines, "- Check run statuses: "+formatStringCounts(statusCounts))
841
+ lines = append(lines, "- Check run conclusions: "+formatStringCounts(conclusionCounts))
842
+ if status.State != "" {
843
+ lines = append(lines, "- Combined commit status: `"+status.State+"`")
844
+ }
845
+ lines = append(lines, "", "## Check run index", "")
846
+ if len(runs) == 0 {
847
+ lines = append(lines, "_No check runs returned by GitHub._")
848
+ }
849
+ for _, run := range runs[:runLimit] {
850
+ lines = append(lines, renderCheckRunIndex(target, run)...)
851
+ }
852
+ if note := githubLocalOmissionNote("check runs", len(runs)-runLimit); note != "" {
853
+ lines = append(lines, "", note)
854
+ }
855
+ lines = append(lines, "", "## Commit statuses", "")
856
+ if status.State != "" {
857
+ lines = append(lines, "Combined status state: `"+status.State+"`", "")
858
+ }
859
+ if len(statuses) == 0 {
860
+ lines = append(lines, "_No commit statuses returned by GitHub._")
861
+ }
862
+ for _, item := range statuses[:statusLimit] {
863
+ contextName := item.Context
864
+ if contextName == "" {
865
+ contextName = "status"
866
+ }
867
+ line := "- **" + contextName + "** — " + item.State
868
+ if item.Description != "" {
869
+ description, truncated := githubOverviewPreview(item.Description, 160)
870
+ line += " — " + description
871
+ if truncated {
872
+ line += " _[description preview truncated]_"
873
+ }
874
+ }
875
+ if item.TargetURL != "" {
876
+ line += " — " + item.TargetURL
877
+ }
878
+ lines = append(lines, line)
879
+ }
880
+ if note := githubLocalOmissionNote("commit statuses", len(statuses)-statusLimit); note != "" {
881
+ lines = append(lines, "", note)
882
+ }
883
+ lines = append(lines, "", "> Check runs and commit statuses are provider facts for the PR head commit; webctx does not infer a branch-protection/merge decision from them.")
884
+ lines = append(lines, "", "## Useful GitHub URLs", "")
885
+ lines = append(lines, pullViewHints(target, "checks")...)
886
+ return strings.TrimSpace(strings.Join(lines, "\n"))
887
+ }
888
+
889
+ func renderCheckRunIndex(target *GitHubTarget, run githubCheckRun) []string {
890
+ name := run.Name
891
+ if name == "" {
892
+ name = fmt.Sprintf("Check run %d", run.ID)
893
+ }
894
+ lines := []string{"### " + name, ""}
895
+ meta := []string{fmt.Sprintf("id %d", run.ID)}
896
+ if run.Status != "" {
897
+ meta = append(meta, "status "+run.Status)
898
+ }
899
+ if run.Conclusion != "" {
900
+ meta = append(meta, "conclusion "+run.Conclusion)
901
+ }
902
+ if run.App != nil && run.App.Slug != "" {
903
+ meta = append(meta, "app "+run.App.Slug)
904
+ }
905
+ if run.Output.AnnotationsCount > 0 {
906
+ meta = append(meta, fmt.Sprintf("%d annotations", run.Output.AnnotationsCount))
907
+ }
908
+ lines = append(lines, "_"+strings.Join(meta, " · ")+"_", "")
909
+ if run.DetailsURL != "" {
910
+ lines = append(lines, "Details: "+run.DetailsURL)
911
+ }
912
+ if run.ID > 0 {
913
+ lines = append(lines, "Focused check: "+pullFocusedCheckURL(target, run.ID))
914
+ }
915
+ lines = append(lines, "")
916
+ return lines
917
+ }
918
+
919
+ func renderGitHubSelectedCheck(target *GitHubTarget, pr githubPullRequest, run githubCheckRun, annotations []githubCheckAnnotation) string {
920
+ annotationLimit := minInt(12, len(annotations))
921
+ summaryRunes := 900
922
+ messageRunes := 320
923
+ rawDetailsRunes := 180
924
+ for {
925
+ out := renderGitHubSelectedCheckWithLimits(target, pr, run, annotations, annotationLimit, summaryRunes, messageRunes, rawDetailsRunes)
926
+ if githubOverviewFits(out) {
927
+ return out
928
+ }
929
+ switch {
930
+ case annotationLimit > 1:
931
+ annotationLimit--
932
+ case summaryRunes > 400:
933
+ summaryRunes = 400
934
+ case messageRunes > 180:
935
+ messageRunes = 180
936
+ case rawDetailsRunes > 100:
937
+ rawDetailsRunes = 100
938
+ default:
939
+ return out
940
+ }
941
+ }
942
+ }
943
+
944
+ func renderGitHubSelectedCheckWithLimits(target *GitHubTarget, pr githubPullRequest, run githubCheckRun, annotations []githubCheckAnnotation, annotationLimit, summaryRunes, messageRunes, rawDetailsRunes int) string {
945
+ annotationLimit = minInt(annotationLimit, len(annotations))
946
+ lines := []string{
947
+ "---",
948
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
949
+ fmt.Sprintf("pull_request: %d", target.Number),
950
+ "view: check",
951
+ fmt.Sprintf("check_run_id: %d", run.ID),
952
+ "head_sha: " + yamlScalar(pr.Head.SHA),
953
+ fmt.Sprintf("annotations_reported: %d", run.Output.AnnotationsCount),
954
+ fmt.Sprintf("annotations_returned: %d", len(annotations)),
955
+ fmt.Sprintf("annotations_indexed: %d", annotationLimit),
956
+ fmt.Sprintf("annotations_local_omitted: %d", len(annotations)-annotationLimit),
957
+ "---",
958
+ "",
959
+ fmt.Sprintf("# Check: %s", run.Name),
960
+ "",
961
+ }
962
+ lines = append(lines, renderFocusedCheckSummary(run, summaryRunes)...)
963
+ lines = append(lines, "## Annotations", "")
964
+ if len(annotations) == 0 {
965
+ lines = append(lines, "_No annotations returned by GitHub._")
966
+ }
967
+ for _, annotation := range annotations[:annotationLimit] {
968
+ coord := annotation.Path
969
+ if annotation.StartLine > 0 {
970
+ coord += fmt.Sprintf(":%d", annotation.StartLine)
971
+ if annotation.EndLine > annotation.StartLine {
972
+ coord += fmt.Sprintf("-%d", annotation.EndLine)
973
+ }
974
+ }
975
+ level := annotation.AnnotationLevel
976
+ if level == "" {
977
+ level = "annotation"
978
+ }
979
+ line := "- **" + level + "**"
980
+ if coord != "" {
981
+ line += " `" + coord + "`"
982
+ }
983
+ if annotation.Title != "" {
984
+ line += " — " + annotation.Title
985
+ }
986
+ if annotation.Message != "" {
987
+ message, truncated := githubOverviewPreview(annotation.Message, messageRunes)
988
+ line += ": " + message
989
+ if truncated {
990
+ line += " _[message preview truncated]_"
991
+ }
992
+ }
993
+ lines = append(lines, line)
994
+ if annotation.RawDetails != "" {
995
+ rawDetails, truncated := githubOverviewPreview(annotation.RawDetails, rawDetailsRunes)
996
+ lines = append(lines, " "+rawDetails)
997
+ if truncated {
998
+ lines = append(lines, " _Raw details preview locally truncated._")
999
+ }
1000
+ }
1001
+ }
1002
+ if note := githubLocalOmissionNote("check annotations", len(annotations)-annotationLimit); note != "" {
1003
+ lines = append(lines, "", note)
1004
+ }
1005
+ if run.Output.AnnotationsCount > len(annotations) {
1006
+ lines = append(lines, "", "> GitHub reports more annotations than were returned by the provider data; this provider-incomplete state is separate from local overview omission.")
1007
+ }
1008
+ lines = append(lines, "", "## Useful GitHub URLs", "")
1009
+ if run.DetailsURL != "" {
1010
+ lines = append(lines, "- Check details: "+run.DetailsURL)
1011
+ }
1012
+ lines = append(lines, pullViewHints(target, "checks")...)
1013
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1014
+ }
1015
+
1016
+ func renderFocusedCheckSummary(run githubCheckRun, summaryRunes int) []string {
1017
+ lines := []string{}
1018
+ meta := []string{fmt.Sprintf("id %d", run.ID)}
1019
+ if run.Status != "" {
1020
+ meta = append(meta, "status "+run.Status)
1021
+ }
1022
+ if run.Conclusion != "" {
1023
+ meta = append(meta, "conclusion "+run.Conclusion)
1024
+ }
1025
+ if run.App != nil && run.App.Slug != "" {
1026
+ meta = append(meta, "app "+run.App.Slug)
1027
+ }
1028
+ lines = append(lines, "_"+strings.Join(meta, " · ")+"_", "")
1029
+ if run.Output.Title != "" {
1030
+ lines = append(lines, "**"+run.Output.Title+"**", "")
1031
+ }
1032
+ if strings.TrimSpace(run.Output.Summary) != "" {
1033
+ summary, truncated := githubOverviewPreview(run.Output.Summary, summaryRunes)
1034
+ lines = append(lines, summary, "")
1035
+ if truncated {
1036
+ lines = append(lines, "> Check summary preview locally truncated; use the provider Details URL for deeper machine output when available.", "")
1037
+ }
1038
+ }
1039
+ if run.DetailsURL != "" {
1040
+ lines = append(lines, "Details: "+run.DetailsURL, "")
1041
+ }
1042
+ return lines
1043
+ }
1044
+
1045
+ func githubCheckRunCounts(runs []githubCheckRun) (map[string]int, map[string]int) {
1046
+ statuses := map[string]int{}
1047
+ conclusions := map[string]int{}
1048
+ for _, run := range runs {
1049
+ status := strings.TrimSpace(run.Status)
1050
+ if status == "" {
1051
+ status = "unknown"
1052
+ }
1053
+ statuses[status]++
1054
+ conclusion := strings.TrimSpace(run.Conclusion)
1055
+ if conclusion == "" {
1056
+ conclusion = "none"
1057
+ }
1058
+ conclusions[conclusion]++
1059
+ }
1060
+ return statuses, conclusions
1061
+ }
1062
+
1063
+ func jsonMapScalar(counts map[string]int) string {
1064
+ encoded, _ := json.Marshal(counts)
1065
+ return string(encoded)
1066
+ }
1067
+
1068
+ func formatStringCounts(counts map[string]int) string {
1069
+ if len(counts) == 0 {
1070
+ return "none"
1071
+ }
1072
+ keys := make([]string, 0, len(counts))
1073
+ for key := range counts {
1074
+ keys = append(keys, key)
1075
+ }
1076
+ sort.Strings(keys)
1077
+ parts := make([]string, 0, len(keys))
1078
+ for _, key := range keys {
1079
+ parts = append(parts, fmt.Sprintf("%s %d", key, counts[key]))
1080
+ }
1081
+ return strings.Join(parts, ", ")
1082
+ }
1083
+
1084
+ func githubCommitStatusPriority(status githubCommitStatus) int {
1085
+ switch strings.ToLower(strings.TrimSpace(status.State)) {
1086
+ case "error", "failure":
1087
+ return 0
1088
+ case "pending":
1089
+ return 1
1090
+ case "success":
1091
+ return 2
1092
+ default:
1093
+ return 0
1094
+ }
1095
+ }
1096
+
1097
+ func pullFocusedCheckURL(target *GitHubTarget, id int64) string {
1098
+ base := fmt.Sprintf("https://github.com/%s/%s/pull/%d/checks", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), target.Number)
1099
+ return base + "?check_run_id=" + strconv.FormatInt(id, 10)
1100
+ }
1101
+
1102
+ func readGitHubPullRawDiff(ctx context.Context, client *GitHubClient, target *GitHubTarget, patch bool) (string, error) {
1103
+ if target.Fragment != "" {
1104
+ return "", fmt.Errorf("GitHub raw Pull Request diff/patch URLs do not support fragment selection in webctx")
1105
+ }
1106
+ endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
1107
+ accept := "application/vnd.github.v3.diff"
1108
+ if patch {
1109
+ accept = "application/vnd.github.v3.patch"
1110
+ }
1111
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, accept)
1112
+ if err != nil {
1113
+ return "", err
1114
+ }
1115
+ return string(resp.Body), nil
1116
+ }
1117
+
1118
+ func pullViewHints(target *GitHubTarget, current string) []string {
1119
+ base := fmt.Sprintf("https://github.com/%s/%s/pull/%d", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), target.Number)
1120
+ views := []struct {
1121
+ name string
1122
+ url string
1123
+ }{
1124
+ {name: "conversation", url: base},
1125
+ {name: "files", url: base + "/files"},
1126
+ {name: "commits", url: base + "/commits"},
1127
+ {name: "checks", url: base + "/checks"},
1128
+ }
1129
+ hints := []string{}
1130
+ for _, view := range views {
1131
+ if view.name == current {
1132
+ continue
1133
+ }
1134
+ label := strings.ToUpper(view.name[:1]) + view.name[1:]
1135
+ hints = append(hints, "- "+label+": "+view.url)
1136
+ if len(hints) == 3 {
1137
+ break
1138
+ }
1139
+ }
1140
+ return hints
1141
+ }