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