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