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,1004 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "net/url"
9
+ "strconv"
10
+ "strings"
11
+ )
12
+
13
+ const (
14
+ githubCommitFilesMax = 3000
15
+ githubCompareFilesMax = 300
16
+ )
17
+
18
+ type githubCommitDetail struct {
19
+ SHA string `json:"sha"`
20
+ HTMLURL string `json:"html_url"`
21
+ Author githubUser `json:"author"`
22
+ Committer githubUser `json:"committer"`
23
+ Commit struct {
24
+ Message string `json:"message"`
25
+ Author struct {
26
+ Name string `json:"name"`
27
+ Email string `json:"email"`
28
+ Date string `json:"date"`
29
+ } `json:"author"`
30
+ Committer struct {
31
+ Name string `json:"name"`
32
+ Email string `json:"email"`
33
+ Date string `json:"date"`
34
+ } `json:"committer"`
35
+ CommentCount int `json:"comment_count"`
36
+ Verification struct {
37
+ Verified bool `json:"verified"`
38
+ Reason string `json:"reason"`
39
+ VerifiedAt string `json:"verified_at"`
40
+ } `json:"verification"`
41
+ } `json:"commit"`
42
+ Stats struct {
43
+ Total int `json:"total"`
44
+ Additions int `json:"additions"`
45
+ Deletions int `json:"deletions"`
46
+ } `json:"stats"`
47
+ Files []githubPullFile `json:"files"`
48
+ Parents []struct {
49
+ SHA string `json:"sha"`
50
+ HTMLURL string `json:"html_url"`
51
+ } `json:"parents"`
52
+ }
53
+
54
+ type githubCommitComment struct {
55
+ ID int64 `json:"id"`
56
+ Body *string `json:"body"`
57
+ HTMLURL string `json:"html_url"`
58
+ CommitID string `json:"commit_id"`
59
+ User githubUser `json:"user"`
60
+ AuthorAssociation string `json:"author_association"`
61
+ Path string `json:"path"`
62
+ Line *int `json:"line"`
63
+ Position *int `json:"position"`
64
+ CreatedAt string `json:"created_at"`
65
+ UpdatedAt string `json:"updated_at"`
66
+ }
67
+
68
+ type githubCommitAvailability struct {
69
+ FilesProviderMore bool
70
+ CommentsProviderMore bool
71
+ }
72
+
73
+ type githubCompareResult struct {
74
+ HTMLURL string `json:"html_url"`
75
+ PermalinkURL string `json:"permalink_url"`
76
+ DiffURL string `json:"diff_url"`
77
+ PatchURL string `json:"patch_url"`
78
+ Status string `json:"status"`
79
+ AheadBy int `json:"ahead_by"`
80
+ BehindBy int `json:"behind_by"`
81
+ TotalCommits int `json:"total_commits"`
82
+ BaseCommit githubPullCommit `json:"base_commit"`
83
+ MergeBaseCommit githubPullCommit `json:"merge_base_commit"`
84
+ Commits []githubPullCommit `json:"commits"`
85
+ Files []githubPullFile `json:"files"`
86
+ }
87
+
88
+ type githubCompareAvailability struct {
89
+ CommitsProviderMore bool
90
+ }
91
+
92
+ type githubBlameRange struct {
93
+ StartingLine int
94
+ EndingLine int
95
+ Age int
96
+ Commit struct {
97
+ OID string
98
+ AbbreviatedOID string
99
+ CommittedDate string
100
+ MessageHeadline string
101
+ URL string
102
+ Author struct {
103
+ Name string
104
+ Email string
105
+ User *struct {
106
+ Login string
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ func readGitHubCommit(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
113
+ if commentID, ok, err := parseCommitCommentSelector(target.Fragment); ok {
114
+ if err != nil {
115
+ return "", err
116
+ }
117
+ return readGitHubCommitComment(ctx, client, target, commentID)
118
+ }
119
+ if selector, ok, err := parseGitHubDiffSelector(target.Fragment); ok {
120
+ if err != nil {
121
+ return "", err
122
+ }
123
+ return readGitHubCommitDiffSelector(ctx, client, target, selector)
124
+ }
125
+ if target.Fragment != "" {
126
+ return "", fmt.Errorf("GitHub commit fragment %q is not a supported native selector", target.Fragment)
127
+ }
128
+ detail, filesMore, err := fetchGitHubCommitPage(ctx, client, target)
129
+ if err != nil {
130
+ return "", err
131
+ }
132
+ comments, commentsMore, err := fetchGitHubCommitCommentsPage(ctx, client, target, detail)
133
+ if err != nil {
134
+ return "", err
135
+ }
136
+ return renderGitHubCommit(target, detail, comments, githubCommitAvailability{FilesProviderMore: filesMore, CommentsProviderMore: commentsMore}), nil
137
+ }
138
+
139
+ func fetchGitHubCommitPage(ctx context.Context, client *GitHubClient, target *GitHubTarget) (githubCommitDetail, bool, error) {
140
+ endpoint := fmt.Sprintf("/repos/%s/%s/commits/%s?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), url.PathEscape(target.Name))
141
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
142
+ if err != nil {
143
+ return githubCommitDetail{}, false, err
144
+ }
145
+ var detail githubCommitDetail
146
+ if err := json.Unmarshal(resp.Body, &detail); err != nil {
147
+ return githubCommitDetail{}, false, fmt.Errorf("decode GitHub commit: %w", err)
148
+ }
149
+ return detail, strings.TrimSpace(resp.Links()["next"]) != "", nil
150
+ }
151
+
152
+ func fetchGitHubCommitAllFiles(ctx context.Context, client *GitHubClient, target *GitHubTarget) (githubCommitDetail, error) {
153
+ endpoint := fmt.Sprintf("/repos/%s/%s/commits/%s?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), url.PathEscape(target.Name))
154
+ pages, err := client.RESTPages(ctx, endpoint, "application/vnd.github+json")
155
+ if err != nil {
156
+ return githubCommitDetail{}, err
157
+ }
158
+ if len(pages) == 0 {
159
+ return githubCommitDetail{}, fmt.Errorf("GitHub returned no commit pages")
160
+ }
161
+ var detail githubCommitDetail
162
+ files := []githubPullFile{}
163
+ for i, page := range pages {
164
+ var current githubCommitDetail
165
+ if err := json.Unmarshal(page.Body, &current); err != nil {
166
+ return githubCommitDetail{}, fmt.Errorf("decode GitHub commit: %w", err)
167
+ }
168
+ if i == 0 {
169
+ detail = current
170
+ }
171
+ files = append(files, current.Files...)
172
+ }
173
+ detail.Files = files
174
+ return detail, nil
175
+ }
176
+
177
+ func fetchGitHubCommitComments(ctx context.Context, client *GitHubClient, target *GitHubTarget, sha string) ([]githubCommitComment, error) {
178
+ endpoint := fmt.Sprintf("/repos/%s/%s/commits/%s/comments?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), url.PathEscape(sha))
179
+ pages, err := client.RESTPages(ctx, endpoint, "application/vnd.github+json")
180
+ if err != nil {
181
+ return nil, err
182
+ }
183
+ comments := []githubCommitComment{}
184
+ for _, page := range pages {
185
+ var batch []githubCommitComment
186
+ if err := json.Unmarshal(page.Body, &batch); err != nil {
187
+ return nil, fmt.Errorf("decode GitHub commit comments: %w", err)
188
+ }
189
+ comments = append(comments, batch...)
190
+ }
191
+ return comments, nil
192
+ }
193
+
194
+ func fetchGitHubCommitCommentsPage(ctx context.Context, client *GitHubClient, target *GitHubTarget, detail githubCommitDetail) ([]githubCommitComment, bool, error) {
195
+ if detail.Commit.CommentCount == 0 {
196
+ return nil, false, nil
197
+ }
198
+ endpoint := fmt.Sprintf("/repos/%s/%s/commits/%s/comments?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), url.PathEscape(detail.SHA))
199
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
200
+ if err != nil {
201
+ return nil, false, err
202
+ }
203
+ var comments []githubCommitComment
204
+ if err := json.Unmarshal(resp.Body, &comments); err != nil {
205
+ return nil, false, fmt.Errorf("decode GitHub commit comments: %w", err)
206
+ }
207
+ return comments, strings.TrimSpace(resp.Links()["next"]) != "", nil
208
+ }
209
+
210
+ func parseCommitCommentSelector(fragment string) (int64, bool, error) {
211
+ if fragment == "" || !strings.HasPrefix(fragment, "commitcomment-") {
212
+ return 0, false, nil
213
+ }
214
+ id, err := strconv.ParseInt(strings.TrimPrefix(fragment, "commitcomment-"), 10, 64)
215
+ if err != nil || id <= 0 {
216
+ return 0, true, fmt.Errorf("invalid GitHub commit-comment selector %q", fragment)
217
+ }
218
+ return id, true, nil
219
+ }
220
+
221
+ func readGitHubCommitComment(ctx context.Context, client *GitHubClient, target *GitHubTarget, commentID int64) (string, error) {
222
+ endpoint := fmt.Sprintf("/repos/%s/%s/comments/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), commentID)
223
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
224
+ if err != nil {
225
+ return "", err
226
+ }
227
+ var comment githubCommitComment
228
+ if err := json.Unmarshal(resp.Body, &comment); err != nil {
229
+ return "", fmt.Errorf("decode GitHub commit comment: %w", err)
230
+ }
231
+ if isHexCommitish(target.Name) {
232
+ if !commitishMatchesSHA(target.Name, comment.CommitID) {
233
+ return "", fmt.Errorf("GitHub commit-comment selector commitcomment-%d belongs to commit %s, not %s", commentID, comment.CommitID, target.Name)
234
+ }
235
+ } else if !commitishMatchesSHA(target.Name, comment.CommitID) {
236
+ detail, _, err := fetchGitHubCommitPage(ctx, client, target)
237
+ if err != nil {
238
+ return "", err
239
+ }
240
+ if !strings.EqualFold(detail.SHA, comment.CommitID) {
241
+ return "", fmt.Errorf("GitHub commit-comment selector commitcomment-%d belongs to commit %s, not %s", commentID, comment.CommitID, detail.SHA)
242
+ }
243
+ }
244
+ return renderGitHubSelectedCommitComment(target, comment), nil
245
+ }
246
+
247
+ func commitishMatchesSHA(commitish, sha string) bool {
248
+ commitish = strings.TrimSpace(commitish)
249
+ sha = strings.TrimSpace(sha)
250
+ if len(commitish) < 7 || len(commitish) > len(sha) {
251
+ return false
252
+ }
253
+ for _, r := range commitish {
254
+ if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'f') && !(r >= 'A' && r <= 'F') {
255
+ return false
256
+ }
257
+ }
258
+ return strings.HasPrefix(strings.ToLower(sha), strings.ToLower(commitish))
259
+ }
260
+
261
+ func isHexCommitish(commitish string) bool {
262
+ commitish = strings.TrimSpace(commitish)
263
+ if len(commitish) < 7 || len(commitish) > 64 {
264
+ return false
265
+ }
266
+ for _, r := range commitish {
267
+ if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'f') && !(r >= 'A' && r <= 'F') {
268
+ return false
269
+ }
270
+ }
271
+ return true
272
+ }
273
+
274
+ func readGitHubCommitDiffSelector(ctx context.Context, client *GitHubClient, target *GitHubTarget, selector githubDiffSelector) (string, error) {
275
+ detail, err := fetchGitHubCommitAllFiles(ctx, client, target)
276
+ if err != nil {
277
+ return "", err
278
+ }
279
+ var selected *githubPullFile
280
+ for i := range detail.Files {
281
+ if githubDiffPathHash(detail.Files[i].Filename) == selector.Hash {
282
+ selected = &detail.Files[i]
283
+ break
284
+ }
285
+ }
286
+ if selected == nil {
287
+ return "", fmt.Errorf("GitHub diff selector %q does not match any changed file in commit %s", target.Fragment, detail.SHA)
288
+ }
289
+ file := *selected
290
+ if selector.Side != 0 {
291
+ if file.Patch == nil || strings.TrimSpace(*file.Patch) == "" {
292
+ return "", fmt.Errorf("GitHub diff line selector %q targets a file whose patch is unavailable from the provider", target.Fragment)
293
+ }
294
+ patch, err := selectGitHubPatchHunks(*file.Patch, selector.Side, selector.Start, selector.End)
295
+ if err != nil {
296
+ return "", fmt.Errorf("GitHub diff line selector %q is stale or out of range: %w", target.Fragment, err)
297
+ }
298
+ file.Patch = &patch
299
+ }
300
+ return renderGitHubSelectedCommitFile(target, detail, file, selector), nil
301
+ }
302
+
303
+ func renderGitHubCommit(target *GitHubTarget, detail githubCommitDetail, comments []githubCommitComment, availability githubCommitAvailability) string {
304
+ fileLimit := minInt(14, len(detail.Files))
305
+ commentLimit := minInt(6, len(comments))
306
+ for {
307
+ out := renderGitHubCommitWithLimits(target, detail, comments, availability, fileLimit, commentLimit)
308
+ if githubOverviewFits(out) {
309
+ return out
310
+ }
311
+ switch {
312
+ case commentLimit > 1:
313
+ commentLimit--
314
+ case fileLimit > 1:
315
+ fileLimit--
316
+ default:
317
+ return out
318
+ }
319
+ }
320
+ }
321
+
322
+ func renderGitHubCommitWithLimits(target *GitHubTarget, detail githubCommitDetail, comments []githubCommitComment, availability githubCommitAvailability, fileLimit, commentLimit int) string {
323
+ fileLimit = minInt(fileLimit, len(detail.Files))
324
+ commentLimit = minInt(commentLimit, len(comments))
325
+ commentReported := detail.Commit.CommentCount
326
+ if commentReported < len(comments) {
327
+ commentReported = len(comments)
328
+ }
329
+ lines := []string{
330
+ "---",
331
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
332
+ "sha: " + yamlScalar(detail.SHA),
333
+ "overview: true",
334
+ fmt.Sprintf("files_returned: %d", len(detail.Files)),
335
+ fmt.Sprintf("files_indexed: %d", fileLimit),
336
+ fmt.Sprintf("files_local_omitted: %d", len(detail.Files)-fileLimit),
337
+ fmt.Sprintf("comments_reported: %d", commentReported),
338
+ fmt.Sprintf("comments_returned: %d", len(comments)),
339
+ fmt.Sprintf("comments_indexed: %d", commentLimit),
340
+ fmt.Sprintf("comments_local_omitted: %d", len(comments)-commentLimit),
341
+ fmt.Sprintf("additions: %d", detail.Stats.Additions),
342
+ fmt.Sprintf("deletions: %d", detail.Stats.Deletions),
343
+ fmt.Sprintf("changes: %d", detail.Stats.Total),
344
+ fmt.Sprintf("provider_file_ceiling: %d", githubCommitFilesMax),
345
+ }
346
+ if availability.FilesProviderMore {
347
+ lines = append(lines, "files_provider_more_available: true")
348
+ }
349
+ if availability.CommentsProviderMore {
350
+ lines = append(lines, "comments_provider_more_available: true")
351
+ } else if commentReported > len(comments) {
352
+ lines = append(lines, "comments_provider_complete: false")
353
+ }
354
+ if detail.Author.Login != "" {
355
+ lines = append(lines, "author: "+yamlScalar("@"+detail.Author.Login))
356
+ } else if detail.Commit.Author.Name != "" {
357
+ lines = append(lines, "author: "+yamlScalar(detail.Commit.Author.Name))
358
+ }
359
+ if detail.Commit.Author.Date != "" {
360
+ lines = append(lines, "authored: "+yamlScalar(detail.Commit.Author.Date))
361
+ }
362
+ if detail.Committer.Login != "" {
363
+ lines = append(lines, "committer: "+yamlScalar("@"+detail.Committer.Login))
364
+ } else if detail.Commit.Committer.Name != "" {
365
+ lines = append(lines, "committer: "+yamlScalar(detail.Commit.Committer.Name))
366
+ }
367
+ if detail.Commit.Committer.Date != "" {
368
+ lines = append(lines, "committed: "+yamlScalar(detail.Commit.Committer.Date))
369
+ }
370
+ lines = append(lines, fmt.Sprintf("verified: %t", detail.Commit.Verification.Verified))
371
+ if detail.Commit.Verification.Reason != "" {
372
+ lines = append(lines, "verification_reason: "+yamlScalar(detail.Commit.Verification.Reason))
373
+ }
374
+ if detail.HTMLURL != "" {
375
+ lines = append(lines, "url: "+yamlScalar(detail.HTMLURL))
376
+ }
377
+ lines = append(lines, "---", "", "# Commit "+shortSHA(detail.SHA), "")
378
+ if strings.TrimSpace(detail.Commit.Message) != "" {
379
+ lines = append(lines, detail.Commit.Message, "")
380
+ }
381
+ if len(detail.Parents) > 0 {
382
+ lines = append(lines, "## Parents", "")
383
+ for _, parent := range detail.Parents {
384
+ label := "`" + shortSHA(parent.SHA) + "`"
385
+ if parent.HTMLURL != "" {
386
+ label = "[" + label + "](" + parent.HTMLURL + ")"
387
+ }
388
+ lines = append(lines, "- "+label)
389
+ }
390
+ lines = append(lines, "")
391
+ }
392
+ lines = append(lines, "## Changed-file index", "")
393
+ if availability.FilesProviderMore {
394
+ lines = append(lines, "> GitHub has more changed files beyond the provider page fetched for this overview. Exact diff selectors and raw `.diff`/`.patch` remain explicit deep paths.", "")
395
+ }
396
+ if len(detail.Files) == 0 {
397
+ lines = append(lines, "_No changed files returned by GitHub._", "")
398
+ }
399
+ for _, file := range detail.Files[:fileLimit] {
400
+ lines = append(lines, renderCommitFileIndex(detail, file)...)
401
+ }
402
+ if note := githubLocalOmissionNote("changed files returned on this provider page", len(detail.Files)-fileLimit); note != "" {
403
+ lines = append(lines, note, "")
404
+ }
405
+ lines = append(lines, "## Commit-comment index", "")
406
+ if len(comments) == 0 {
407
+ lines = append(lines, "_No commit comments returned on this provider page._")
408
+ }
409
+ for _, comment := range comments[:commentLimit] {
410
+ lines = append(lines, renderGitHubCommitCommentIndex(target, detail, comment)...)
411
+ }
412
+ if note := githubLocalOmissionNote("commit comments returned on this provider page", len(comments)-commentLimit); note != "" {
413
+ lines = append(lines, note)
414
+ }
415
+ if availability.CommentsProviderMore {
416
+ lines = append(lines, "", "> GitHub has more commit comments beyond the provider page fetched for this overview.")
417
+ } else if commentReported > len(comments) {
418
+ lines = append(lines, "", "> GitHub reports more commit comments than were returned by the fetched provider data; this provider-incomplete state is separate from local overview omission.")
419
+ }
420
+ lines = append(lines, "", "## Raw GitHub representations", "", "- Diff: "+commitRawURL(detail, target, ".diff"), "- Patch: "+commitRawURL(detail, target, ".patch"))
421
+ return strings.TrimSpace(strings.Join(lines, "\n"))
422
+ }
423
+
424
+ func renderCommitFileIndex(detail githubCommitDetail, file githubPullFile) []string {
425
+ lines := []string{"### `" + file.Filename + "`", ""}
426
+ meta := []string{}
427
+ if file.Status != "" {
428
+ meta = append(meta, "status "+file.Status)
429
+ }
430
+ meta = append(meta, fmt.Sprintf("+%d", file.Additions), fmt.Sprintf("-%d", file.Deletions), fmt.Sprintf("%d changes", file.Changes))
431
+ if file.PreviousFilename != "" {
432
+ meta = append(meta, "renamed from `"+file.PreviousFilename+"`")
433
+ }
434
+ lines = append(lines, "_"+strings.Join(meta, " · ")+"_")
435
+ base := detail.HTMLURL
436
+ if base != "" {
437
+ lines = append(lines, "Selector: "+base+"#diff-"+githubDiffPathHash(file.Filename))
438
+ }
439
+ if file.BlobURL != "" {
440
+ lines = append(lines, "Blob: "+file.BlobURL)
441
+ }
442
+ if file.RawURL != "" {
443
+ lines = append(lines, "Raw: "+file.RawURL)
444
+ }
445
+ lines = append(lines, "")
446
+ return lines
447
+ }
448
+
449
+ func renderGitHubCommitCommentIndex(target *GitHubTarget, detail githubCommitDetail, comment githubCommitComment) []string {
450
+ heading := fmt.Sprintf("### Comment `%d`", comment.ID)
451
+ if comment.User.Login != "" {
452
+ heading += " by @" + comment.User.Login
453
+ }
454
+ if comment.CreatedAt != "" {
455
+ heading += " — " + comment.CreatedAt
456
+ }
457
+ lines := []string{heading, ""}
458
+ if coord := commitCommentCoordinate(comment); coord != "" {
459
+ lines = append(lines, "Location: "+coord, "")
460
+ }
461
+ body := "_Commit comment body is unavailable or deleted._"
462
+ if comment.Body != nil {
463
+ body = strings.TrimSpace(stripInvisibleHTMLComments(*comment.Body))
464
+ if body == "" {
465
+ body = "_Comment is empty after removing invisible GitHub markup._"
466
+ }
467
+ }
468
+ preview, truncated := githubOverviewPreview(body, githubIndexPreviewRunes)
469
+ for _, line := range strings.Split(preview, "\n") {
470
+ lines = append(lines, "> "+line)
471
+ }
472
+ if truncated {
473
+ lines = append(lines, "> _Preview locally truncated._")
474
+ }
475
+ selector := comment.HTMLURL
476
+ if selector == "" {
477
+ base := detail.HTMLURL
478
+ if base == "" {
479
+ base = fmt.Sprintf("https://github.com/%s/%s/commit/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), detail.SHA)
480
+ }
481
+ selector = base + "#commitcomment-" + strconv.FormatInt(comment.ID, 10)
482
+ }
483
+ lines = append(lines, "", "Selector: "+selector, "")
484
+ return lines
485
+ }
486
+
487
+ func commitCommentCoordinate(comment githubCommitComment) string {
488
+ if comment.Path == "" {
489
+ return ""
490
+ }
491
+ coord := "`" + comment.Path + "`"
492
+ if comment.Line != nil {
493
+ coord += fmt.Sprintf(" line %d", *comment.Line)
494
+ }
495
+ return coord
496
+ }
497
+
498
+ func renderGitHubSelectedCommitComment(target *GitHubTarget, comment githubCommitComment) string {
499
+ lines := []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), "commit: " + yamlScalar(comment.CommitID), fmt.Sprintf("comment_id: %d", comment.ID)}
500
+ if comment.HTMLURL != "" {
501
+ lines = append(lines, "url: "+yamlScalar(comment.HTMLURL))
502
+ }
503
+ lines = append(lines, "---", "", fmt.Sprintf("# Commit comment %d", comment.ID), "")
504
+ lines = append(lines, renderGitHubCommitComment(comment)...)
505
+ return strings.TrimSpace(strings.Join(lines, "\n"))
506
+ }
507
+
508
+ func renderGitHubSelectedCommitFile(target *GitHubTarget, detail githubCommitDetail, file githubPullFile, selector githubDiffSelector) string {
509
+ lines := []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), "commit: " + yamlScalar(detail.SHA), "selector: " + yamlScalar(target.Fragment), "file: " + yamlScalar(file.Filename), "---", "", fmt.Sprintf("# Commit %s — selected diff", shortSHA(detail.SHA)), ""}
510
+ lines = append(lines, renderGitHubPullFile(file, selector, true)...)
511
+ lines = append(lines, "## Raw GitHub representations", "", "- Diff: "+commitRawURL(detail, target, ".diff"), "- Patch: "+commitRawURL(detail, target, ".patch"))
512
+ return strings.TrimSpace(strings.Join(lines, "\n"))
513
+ }
514
+
515
+ func commitRawURL(detail githubCommitDetail, target *GitHubTarget, suffix string) string {
516
+ base := strings.TrimSpace(detail.HTMLURL)
517
+ if base == "" {
518
+ sha := detail.SHA
519
+ if sha == "" {
520
+ sha = target.Name
521
+ }
522
+ base = fmt.Sprintf("https://github.com/%s/%s/commit/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), sha)
523
+ }
524
+ return strings.TrimRight(base, "/") + suffix
525
+ }
526
+
527
+ func renderGitHubCommitComment(comment githubCommitComment) []string {
528
+ heading := "### Comment"
529
+ if comment.User.Login != "" {
530
+ heading += " by @" + comment.User.Login
531
+ }
532
+ if comment.CreatedAt != "" {
533
+ heading += " — " + comment.CreatedAt
534
+ }
535
+ lines := []string{heading, ""}
536
+ meta := []string{}
537
+ if comment.AuthorAssociation != "" {
538
+ meta = append(meta, comment.AuthorAssociation)
539
+ }
540
+ if comment.Path != "" {
541
+ coord := "`" + comment.Path + "`"
542
+ if comment.Line != nil {
543
+ coord += fmt.Sprintf(" line %d", *comment.Line)
544
+ }
545
+ meta = append(meta, coord)
546
+ }
547
+ if comment.HTMLURL != "" {
548
+ meta = append(meta, comment.HTMLURL)
549
+ }
550
+ if len(meta) > 0 {
551
+ lines = append(lines, "_"+strings.Join(meta, " · ")+"_", "")
552
+ }
553
+ if comment.Body == nil {
554
+ lines = append(lines, "_Commit comment body is unavailable or deleted._")
555
+ } else {
556
+ body := strings.TrimSpace(stripInvisibleHTMLComments(*comment.Body))
557
+ if body == "" {
558
+ body = "_Comment is empty after removing invisible GitHub markup._"
559
+ }
560
+ lines = append(lines, body)
561
+ }
562
+ lines = append(lines, "")
563
+ return lines
564
+ }
565
+
566
+ func readGitHubCommitRawDiff(ctx context.Context, client *GitHubClient, target *GitHubTarget, patch bool) (string, error) {
567
+ if target.Fragment != "" {
568
+ return "", fmt.Errorf("GitHub raw commit diff/patch URLs do not support fragment selection in webctx")
569
+ }
570
+ endpoint := fmt.Sprintf("/repos/%s/%s/commits/%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), url.PathEscape(target.Name))
571
+ accept := "application/vnd.github.v3.diff"
572
+ if patch {
573
+ accept = "application/vnd.github.v3.patch"
574
+ }
575
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, accept)
576
+ if err != nil {
577
+ return "", err
578
+ }
579
+ return string(resp.Body), nil
580
+ }
581
+
582
+ func readGitHubCompare(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
583
+ if target.Fragment != "" {
584
+ return "", fmt.Errorf("GitHub compare fragment %q is not a supported native selector", target.Fragment)
585
+ }
586
+ base, head, err := githubCompareRefs(target)
587
+ if err != nil {
588
+ return "", err
589
+ }
590
+ endpoint := githubCompareEndpoint(target.Owner, target.Repo, base, head) + "?per_page=100"
591
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
592
+ if err != nil {
593
+ return "", err
594
+ }
595
+ var result githubCompareResult
596
+ if err := json.Unmarshal(resp.Body, &result); err != nil {
597
+ return "", fmt.Errorf("decode GitHub comparison: %w", err)
598
+ }
599
+ filesComplete := len(result.Files) < githubCompareFilesMax
600
+ availability := githubCompareAvailability{CommitsProviderMore: strings.TrimSpace(resp.Links()["next"]) != ""}
601
+ return renderGitHubCompare(target, base, head, result, availability, filesComplete), nil
602
+ }
603
+
604
+ func githubCompareRefs(target *GitHubTarget) (string, string, error) {
605
+ if target == nil || len(target.Tail) != 2 || strings.TrimSpace(target.Tail[0]) == "" || strings.TrimSpace(target.Tail[1]) == "" {
606
+ return "", "", fmt.Errorf("GitHub compare URL is missing base/head refs")
607
+ }
608
+ return target.Tail[0], target.Tail[1], nil
609
+ }
610
+
611
+ func githubCompareEndpoint(owner, repo, base, head string) string {
612
+ basehead := base + "..." + head
613
+ return fmt.Sprintf("/repos/%s/%s/compare/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(basehead))
614
+ }
615
+
616
+ func renderGitHubCompare(target *GitHubTarget, base, head string, result githubCompareResult, availability githubCompareAvailability, filesComplete bool) string {
617
+ commitLimit := minInt(20, len(result.Commits))
618
+ fileLimit := minInt(14, len(result.Files))
619
+ for {
620
+ out := renderGitHubCompareWithLimits(target, base, head, result, availability, filesComplete, commitLimit, fileLimit)
621
+ if githubOverviewFits(out) {
622
+ return out
623
+ }
624
+ switch {
625
+ case fileLimit > 1:
626
+ fileLimit--
627
+ case commitLimit > 1:
628
+ commitLimit--
629
+ default:
630
+ return out
631
+ }
632
+ }
633
+ }
634
+
635
+ func renderGitHubCompareWithLimits(target *GitHubTarget, base, head string, result githubCompareResult, availability githubCompareAvailability, filesComplete bool, commitLimit, fileLimit int) string {
636
+ commitLimit = minInt(commitLimit, len(result.Commits))
637
+ fileLimit = minInt(fileLimit, len(result.Files))
638
+ commitsProviderComplete := !availability.CommitsProviderMore && result.TotalCommits <= len(result.Commits)
639
+ lines := []string{
640
+ "---",
641
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
642
+ "base: " + yamlScalar(base),
643
+ "head: " + yamlScalar(head),
644
+ "status: " + yamlScalar(result.Status),
645
+ "overview: true",
646
+ fmt.Sprintf("ahead_by: %d", result.AheadBy),
647
+ fmt.Sprintf("behind_by: %d", result.BehindBy),
648
+ fmt.Sprintf("total_commits: %d", result.TotalCommits),
649
+ fmt.Sprintf("commits_returned: %d", len(result.Commits)),
650
+ fmt.Sprintf("commits_indexed: %d", commitLimit),
651
+ fmt.Sprintf("commits_local_omitted: %d", len(result.Commits)-commitLimit),
652
+ fmt.Sprintf("commits_provider_complete: %t", commitsProviderComplete),
653
+ fmt.Sprintf("files_returned: %d", len(result.Files)),
654
+ fmt.Sprintf("files_indexed: %d", fileLimit),
655
+ fmt.Sprintf("files_local_omitted: %d", len(result.Files)-fileLimit),
656
+ fmt.Sprintf("files_complete: %t", filesComplete),
657
+ fmt.Sprintf("provider_file_ceiling: %d", githubCompareFilesMax),
658
+ }
659
+ if availability.CommitsProviderMore {
660
+ lines = append(lines, "commits_provider_more_available: true")
661
+ }
662
+ if result.HTMLURL != "" {
663
+ lines = append(lines, "url: "+yamlScalar(result.HTMLURL))
664
+ }
665
+ lines = append(lines, "---", "", fmt.Sprintf("# Compare %s...%s", base, head), "", "## Commit index", "")
666
+ if availability.CommitsProviderMore {
667
+ lines = append(lines, "> GitHub has more commits beyond the provider page fetched for this overview.", "")
668
+ } else if !commitsProviderComplete {
669
+ lines = append(lines, "> GitHub reported more commits than were returned by the fetched provider data; this provider-incomplete state is separate from local overview omission.", "")
670
+ }
671
+ if len(result.Commits) == 0 {
672
+ lines = append(lines, "_No comparison commits returned by GitHub._")
673
+ }
674
+ for _, commit := range result.Commits[:commitLimit] {
675
+ message := actionsListLabel(firstLine(commit.Commit.Message))
676
+ label := "`" + shortSHA(commit.SHA) + "`"
677
+ if commit.HTMLURL != "" {
678
+ label = "[" + label + "](" + commit.HTMLURL + ")"
679
+ }
680
+ line := "- " + label
681
+ if message != "" {
682
+ line += " — " + message
683
+ }
684
+ if commit.Commit.Author.Date != "" {
685
+ line += " — " + commit.Commit.Author.Date
686
+ }
687
+ lines = append(lines, line)
688
+ }
689
+ if note := githubLocalOmissionNote("commits returned on this provider page", len(result.Commits)-commitLimit); note != "" {
690
+ lines = append(lines, "", note)
691
+ }
692
+ lines = append(lines, "", "## Changed-file index", "")
693
+ if !filesComplete {
694
+ lines = append(lines, "> GitHub exposes comparison file metadata only on the first page and up to 300 files. Provider ceiling and local overview omission are separate facts.", "")
695
+ }
696
+ if len(result.Files) == 0 {
697
+ lines = append(lines, "_No changed files returned by GitHub._")
698
+ }
699
+ for _, file := range result.Files[:fileLimit] {
700
+ lines = append(lines, renderCompareFileIndex(file)...)
701
+ }
702
+ if note := githubLocalOmissionNote("changed files returned by the comparison provider response", len(result.Files)-fileLimit); note != "" {
703
+ lines = append(lines, note)
704
+ }
705
+ lines = append(lines, "", "## Raw GitHub representations", "", "- Diff: "+compareRawURL(target, base, head, ".diff"), "- Patch: "+compareRawURL(target, base, head, ".patch"))
706
+ return strings.TrimSpace(strings.Join(lines, "\n"))
707
+ }
708
+
709
+ func renderCompareFileIndex(file githubPullFile) []string {
710
+ lines := []string{"### `" + file.Filename + "`", ""}
711
+ meta := []string{}
712
+ if file.Status != "" {
713
+ meta = append(meta, "status "+file.Status)
714
+ }
715
+ meta = append(meta, fmt.Sprintf("+%d", file.Additions), fmt.Sprintf("-%d", file.Deletions), fmt.Sprintf("%d changes", file.Changes))
716
+ if file.PreviousFilename != "" {
717
+ meta = append(meta, "renamed from `"+file.PreviousFilename+"`")
718
+ }
719
+ lines = append(lines, "_"+strings.Join(meta, " · ")+"_")
720
+ if file.BlobURL != "" {
721
+ lines = append(lines, "Blob: "+file.BlobURL)
722
+ }
723
+ if file.RawURL != "" {
724
+ lines = append(lines, "Raw: "+file.RawURL)
725
+ }
726
+ lines = append(lines, "")
727
+ return lines
728
+ }
729
+
730
+ func compareRawURL(target *GitHubTarget, base, head, suffix string) string {
731
+ return fmt.Sprintf("https://github.com/%s/%s/compare/%s...%s%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), escapePathPreservingSlashes(base), escapePathPreservingSlashes(head), suffix)
732
+ }
733
+
734
+ func readGitHubCompareRawDiff(ctx context.Context, client *GitHubClient, target *GitHubTarget, patch bool) (string, error) {
735
+ if target.Fragment != "" {
736
+ return "", fmt.Errorf("GitHub raw comparison diff/patch URLs do not support fragment selection in webctx")
737
+ }
738
+ base, head, err := githubCompareRefs(target)
739
+ if err != nil {
740
+ return "", err
741
+ }
742
+ accept := "application/vnd.github.v3.diff"
743
+ if patch {
744
+ accept = "application/vnd.github.v3.patch"
745
+ }
746
+ resp, err := client.REST(ctx, http.MethodGet, githubCompareEndpoint(target.Owner, target.Repo, base, head), accept)
747
+ if err != nil {
748
+ return "", err
749
+ }
750
+ return string(resp.Body), nil
751
+ }
752
+
753
+ func resolveGitHubHistoryRefPath(ctx context.Context, client *GitHubClient, target *GitHubTarget) (resolvedGitHubPath, error) {
754
+ if target == nil || len(target.Tail) == 0 {
755
+ return resolvedGitHubPath{}, fmt.Errorf("GitHub history URL is missing a ref")
756
+ }
757
+ matches := []resolvedGitHubPath{}
758
+ var lastNotFound error
759
+ for split := 1; split <= len(target.Tail); split++ {
760
+ ref := strings.Join(target.Tail[:split], "/")
761
+ filePath := strings.Join(target.Tail[split:], "/")
762
+ query := url.Values{"sha": []string{ref}, "per_page": []string{"1"}}
763
+ if filePath != "" {
764
+ query.Set("path", filePath)
765
+ }
766
+ endpoint := fmt.Sprintf("/repos/%s/%s/commits?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
767
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
768
+ if err != nil {
769
+ var ghErr *GitHubError
770
+ if asGitHubError(err, &ghErr) && ghErr.Kind == GitHubErrorNotFound {
771
+ lastNotFound = err
772
+ continue
773
+ }
774
+ return resolvedGitHubPath{}, err
775
+ }
776
+ var commits []githubPullCommit
777
+ if err := json.Unmarshal(resp.Body, &commits); err != nil {
778
+ return resolvedGitHubPath{}, fmt.Errorf("decode GitHub history resolver response: %w", err)
779
+ }
780
+ if len(commits) == 0 {
781
+ continue
782
+ }
783
+ matches = append(matches, resolvedGitHubPath{Ref: ref, Path: filePath, Endpoint: endpoint, Response: resp})
784
+ }
785
+ if len(matches) == 1 {
786
+ return matches[0], nil
787
+ }
788
+ if len(matches) > 1 {
789
+ return resolvedGitHubPath{}, &githubAmbiguousRefError{candidates: matches}
790
+ }
791
+ if lastNotFound != nil {
792
+ return resolvedGitHubPath{}, lastNotFound
793
+ }
794
+ return resolvedGitHubPath{}, &GitHubError{Kind: GitHubErrorNotFound, StatusCode: http.StatusNotFound, HasToken: client.hasToken()}
795
+ }
796
+
797
+ func readGitHubHistory(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
798
+ if target.Fragment != "" {
799
+ return "", fmt.Errorf("GitHub commit-history fragment %q is not a supported native selector", target.Fragment)
800
+ }
801
+ resolved, err := resolveGitHubRefPath(ctx, client, target, "history")
802
+ if err != nil {
803
+ return "", err
804
+ }
805
+ query := url.Values{"sha": []string{resolved.Ref}, "per_page": []string{strconv.Itoa(githubPageableListSize)}}
806
+ if resolved.Path != "" {
807
+ query.Set("path", resolved.Path)
808
+ }
809
+ page := 1
810
+ if rawPage := target.Query.Get("page"); rawPage != "" {
811
+ parsed, err := strconv.Atoi(rawPage)
812
+ if err != nil || parsed <= 0 {
813
+ return "", fmt.Errorf("invalid GitHub commit-history page %q", rawPage)
814
+ }
815
+ page = parsed
816
+ query.Set("page", rawPage)
817
+ }
818
+ endpoint := fmt.Sprintf("/repos/%s/%s/commits?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
819
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
820
+ if err != nil {
821
+ return "", err
822
+ }
823
+ var commits []githubPullCommit
824
+ if err := json.Unmarshal(resp.Body, &commits); err != nil {
825
+ return "", fmt.Errorf("decode GitHub commit history: %w", err)
826
+ }
827
+ return renderGitHubHistory(target, resolved, page, commits, resp.Links()), nil
828
+ }
829
+
830
+ func renderGitHubHistory(target *GitHubTarget, resolved resolvedGitHubPath, page int, commits []githubPullCommit, links GitHubLinkRelations) string {
831
+ return renderGitHubHistoryWithLimit(target, resolved, page, commits, links, len(commits))
832
+ }
833
+
834
+ func renderGitHubHistoryWithLimit(target *GitHubTarget, resolved resolvedGitHubPath, page int, commits []githubPullCommit, links GitHubLinkRelations, limit int) string {
835
+ limit = minInt(limit, len(commits))
836
+ lines := []string{
837
+ "---",
838
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
839
+ "ref: " + yamlScalar(resolved.Ref),
840
+ "path: " + yamlScalar(resolved.Path),
841
+ fmt.Sprintf("page: %d", page),
842
+ fmt.Sprintf("commits_returned: %d", len(commits)),
843
+ fmt.Sprintf("commits_indexed: %d", limit),
844
+ fmt.Sprintf("commits_local_omitted: %d", len(commits)-limit),
845
+ "---",
846
+ "",
847
+ }
848
+ title := "Commit history for " + resolved.Ref
849
+ if resolved.Path != "" {
850
+ title += " / " + resolved.Path
851
+ }
852
+ lines = append(lines, "# "+title, "")
853
+ if len(commits) == 0 {
854
+ lines = append(lines, "_No commits on this page._")
855
+ }
856
+ for _, commit := range commits[:limit] {
857
+ message, truncated := githubOverviewInlinePreview(firstLine(commit.Commit.Message), 140)
858
+ if truncated {
859
+ message += "…"
860
+ }
861
+ label := "`" + shortSHA(commit.SHA) + "`"
862
+ if commit.HTMLURL != "" {
863
+ label = "[" + label + "](" + commit.HTMLURL + ")"
864
+ }
865
+ meta := []string{}
866
+ if commit.Author.Login != "" {
867
+ meta = append(meta, "@"+commit.Author.Login)
868
+ } else if commit.Commit.Author.Name != "" {
869
+ author, truncated := githubOverviewInlinePreview(commit.Commit.Author.Name, 80)
870
+ if truncated {
871
+ author += "…"
872
+ }
873
+ meta = append(meta, author)
874
+ }
875
+ if commit.Commit.Author.Date != "" {
876
+ meta = append(meta, commit.Commit.Author.Date)
877
+ }
878
+ line := "- " + label
879
+ if message != "" {
880
+ line += " " + message
881
+ }
882
+ if len(meta) > 0 {
883
+ line += " — " + strings.Join(meta, " · ")
884
+ }
885
+ lines = append(lines, line)
886
+ }
887
+ if nav := renderGitHubUIPageNavigation(target, links); len(nav) > 0 {
888
+ lines = append(lines, "", "## Navigation", "")
889
+ lines = append(lines, nav...)
890
+ }
891
+ return strings.TrimSpace(strings.Join(lines, "\n"))
892
+ }
893
+
894
+ func readGitHubBlame(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
895
+ if target.Fragment != "" {
896
+ return "", fmt.Errorf("GitHub blame fragment %q is not a supported native selector", target.Fragment)
897
+ }
898
+ if !client.hasToken() {
899
+ return "", fmt.Errorf("GitHub blame requires authentication. Set GH_TOKEN or GITHUB_TOKEN")
900
+ }
901
+ resolved, err := resolveGitHubRefPath(ctx, client, target, "file")
902
+ if err != nil {
903
+ return "", err
904
+ }
905
+ const query = `query($owner:String!,$repo:String!,$expression:String!,$path:String!){repository(owner:$owner,name:$repo){object(expression:$expression){... on Commit{oid blame(path:$path){ranges{startingLine endingLine age commit{oid abbreviatedOid committedDate messageHeadline url author{name email user{login}}}}}}}}}`
906
+ variables := map[string]any{"owner": target.Owner, "repo": target.Repo, "expression": resolved.Ref, "path": resolved.Path}
907
+ var data struct {
908
+ Repository *struct {
909
+ Object *struct {
910
+ OID string `json:"oid"`
911
+ Blame struct {
912
+ Ranges []struct {
913
+ StartingLine int `json:"startingLine"`
914
+ EndingLine int `json:"endingLine"`
915
+ Age int `json:"age"`
916
+ Commit struct {
917
+ OID string `json:"oid"`
918
+ AbbreviatedOID string `json:"abbreviatedOid"`
919
+ CommittedDate string `json:"committedDate"`
920
+ MessageHeadline string `json:"messageHeadline"`
921
+ URL string `json:"url"`
922
+ Author struct {
923
+ Name string `json:"name"`
924
+ Email string `json:"email"`
925
+ User *struct {
926
+ Login string `json:"login"`
927
+ } `json:"user"`
928
+ } `json:"author"`
929
+ } `json:"commit"`
930
+ } `json:"ranges"`
931
+ } `json:"blame"`
932
+ } `json:"object"`
933
+ } `json:"repository"`
934
+ }
935
+ if err := client.GraphQL(ctx, query, variables, &data); err != nil {
936
+ return "", err
937
+ }
938
+ if data.Repository == nil || data.Repository.Object == nil {
939
+ return "", fmt.Errorf("GitHub blame target was not available from GraphQL")
940
+ }
941
+ ranges := make([]githubBlameRange, 0, len(data.Repository.Object.Blame.Ranges))
942
+ for _, item := range data.Repository.Object.Blame.Ranges {
943
+ var r githubBlameRange
944
+ r.StartingLine = item.StartingLine
945
+ r.EndingLine = item.EndingLine
946
+ r.Age = item.Age
947
+ r.Commit.OID = item.Commit.OID
948
+ r.Commit.AbbreviatedOID = item.Commit.AbbreviatedOID
949
+ r.Commit.CommittedDate = item.Commit.CommittedDate
950
+ r.Commit.MessageHeadline = item.Commit.MessageHeadline
951
+ r.Commit.URL = item.Commit.URL
952
+ r.Commit.Author.Name = item.Commit.Author.Name
953
+ r.Commit.Author.Email = item.Commit.Author.Email
954
+ if item.Commit.Author.User != nil {
955
+ r.Commit.Author.User = &struct{ Login string }{Login: item.Commit.Author.User.Login}
956
+ }
957
+ ranges = append(ranges, r)
958
+ }
959
+ return renderGitHubBlame(target, resolved, data.Repository.Object.OID, ranges), nil
960
+ }
961
+
962
+ func renderGitHubBlame(target *GitHubTarget, resolved resolvedGitHubPath, oid string, ranges []githubBlameRange) string {
963
+ lines := []string{
964
+ "---",
965
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
966
+ "ref: " + yamlScalar(resolved.Ref),
967
+ "path: " + yamlScalar(resolved.Path),
968
+ "commit: " + yamlScalar(oid),
969
+ fmt.Sprintf("ranges: %d", len(ranges)),
970
+ "---",
971
+ "",
972
+ "# Blame: " + resolved.Path,
973
+ "",
974
+ }
975
+ if len(ranges) == 0 {
976
+ lines = append(lines, "_GitHub returned no blame ranges._")
977
+ }
978
+ for _, r := range ranges {
979
+ sha := r.Commit.AbbreviatedOID
980
+ if sha == "" {
981
+ sha = shortSHA(r.Commit.OID)
982
+ }
983
+ label := "`" + sha + "`"
984
+ if r.Commit.URL != "" {
985
+ label = "[" + label + "](" + r.Commit.URL + ")"
986
+ }
987
+ author := r.Commit.Author.Name
988
+ if r.Commit.Author.User != nil && r.Commit.Author.User.Login != "" {
989
+ author = "@" + r.Commit.Author.User.Login
990
+ }
991
+ line := fmt.Sprintf("- Lines %d-%d — %s", r.StartingLine, r.EndingLine, label)
992
+ if author != "" {
993
+ line += " — " + author
994
+ }
995
+ if r.Commit.CommittedDate != "" {
996
+ line += " — " + r.Commit.CommittedDate
997
+ }
998
+ if r.Commit.MessageHeadline != "" {
999
+ line += " — " + r.Commit.MessageHeadline
1000
+ }
1001
+ lines = append(lines, line)
1002
+ }
1003
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1004
+ }