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,1008 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"archive/zip"
|
|
5
|
+
"bytes"
|
|
6
|
+
"context"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/url"
|
|
12
|
+
"path"
|
|
13
|
+
"sort"
|
|
14
|
+
"strconv"
|
|
15
|
+
"strings"
|
|
16
|
+
"unicode/utf8"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
type githubWorkflow struct {
|
|
20
|
+
ID int64 `json:"id"`
|
|
21
|
+
Name string `json:"name"`
|
|
22
|
+
Path string `json:"path"`
|
|
23
|
+
State string `json:"state"`
|
|
24
|
+
HTMLURL string `json:"html_url"`
|
|
25
|
+
BadgeURL string `json:"badge_url"`
|
|
26
|
+
CreatedAt string `json:"created_at"`
|
|
27
|
+
UpdatedAt string `json:"updated_at"`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type githubWorkflowList struct {
|
|
31
|
+
TotalCount int `json:"total_count"`
|
|
32
|
+
Workflows []githubWorkflow `json:"workflows"`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type githubActionsRun struct {
|
|
36
|
+
ID int64 `json:"id"`
|
|
37
|
+
Name string `json:"name"`
|
|
38
|
+
DisplayTitle string `json:"display_title"`
|
|
39
|
+
RunNumber int `json:"run_number"`
|
|
40
|
+
RunAttempt int `json:"run_attempt"`
|
|
41
|
+
Event string `json:"event"`
|
|
42
|
+
Status string `json:"status"`
|
|
43
|
+
Conclusion string `json:"conclusion"`
|
|
44
|
+
WorkflowID int64 `json:"workflow_id"`
|
|
45
|
+
HeadBranch string `json:"head_branch"`
|
|
46
|
+
HeadSHA string `json:"head_sha"`
|
|
47
|
+
Path string `json:"path"`
|
|
48
|
+
HTMLURL string `json:"html_url"`
|
|
49
|
+
CreatedAt string `json:"created_at"`
|
|
50
|
+
UpdatedAt string `json:"updated_at"`
|
|
51
|
+
RunStartedAt string `json:"run_started_at"`
|
|
52
|
+
Actor githubUser `json:"actor"`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type githubWorkflowRunsPage struct {
|
|
56
|
+
TotalCount int `json:"total_count"`
|
|
57
|
+
WorkflowRuns []githubActionsRun `json:"workflow_runs"`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type githubActionsStep struct {
|
|
61
|
+
Name string `json:"name"`
|
|
62
|
+
Status string `json:"status"`
|
|
63
|
+
Conclusion string `json:"conclusion"`
|
|
64
|
+
Number int `json:"number"`
|
|
65
|
+
StartedAt string `json:"started_at"`
|
|
66
|
+
CompletedAt string `json:"completed_at"`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type githubActionsJob struct {
|
|
70
|
+
ID int64 `json:"id"`
|
|
71
|
+
RunID int64 `json:"run_id"`
|
|
72
|
+
RunAttempt int `json:"run_attempt"`
|
|
73
|
+
HeadSHA string `json:"head_sha"`
|
|
74
|
+
Name string `json:"name"`
|
|
75
|
+
WorkflowName string `json:"workflow_name"`
|
|
76
|
+
HeadBranch string `json:"head_branch"`
|
|
77
|
+
Status string `json:"status"`
|
|
78
|
+
Conclusion string `json:"conclusion"`
|
|
79
|
+
HTMLURL string `json:"html_url"`
|
|
80
|
+
CheckRunURL string `json:"check_run_url"`
|
|
81
|
+
CreatedAt string `json:"created_at"`
|
|
82
|
+
StartedAt string `json:"started_at"`
|
|
83
|
+
CompletedAt string `json:"completed_at"`
|
|
84
|
+
RunnerName string `json:"runner_name"`
|
|
85
|
+
RunnerGroup string `json:"runner_group_name"`
|
|
86
|
+
Labels []string `json:"labels"`
|
|
87
|
+
Steps []githubActionsStep `json:"steps"`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type githubActionsJobsPage struct {
|
|
91
|
+
TotalCount int `json:"total_count"`
|
|
92
|
+
Jobs []githubActionsJob `json:"jobs"`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type githubActionsArtifact struct {
|
|
96
|
+
ID int64 `json:"id"`
|
|
97
|
+
Name string `json:"name"`
|
|
98
|
+
SizeInBytes int64 `json:"size_in_bytes"`
|
|
99
|
+
ArchiveDownloadURL string `json:"archive_download_url"`
|
|
100
|
+
Expired bool `json:"expired"`
|
|
101
|
+
CreatedAt string `json:"created_at"`
|
|
102
|
+
UpdatedAt string `json:"updated_at"`
|
|
103
|
+
ExpiresAt string `json:"expires_at"`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type githubActionsArtifactsPage struct {
|
|
107
|
+
TotalCount int `json:"total_count"`
|
|
108
|
+
Artifacts []githubActionsArtifact `json:"artifacts"`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type githubJobLog struct {
|
|
112
|
+
Text string
|
|
113
|
+
Unavailable string
|
|
114
|
+
TooLarge bool
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type githubActionsRunAvailability struct {
|
|
118
|
+
JobsReported int
|
|
119
|
+
ArtifactsReported int
|
|
120
|
+
ArtifactsProviderMore bool
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type githubJobLogPreview struct {
|
|
124
|
+
Text string
|
|
125
|
+
Strategy string
|
|
126
|
+
Truncated bool
|
|
127
|
+
LinesTotal int
|
|
128
|
+
LinesShown int
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func readGitHubActionsOverview(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
132
|
+
if target.Fragment != "" {
|
|
133
|
+
return "", fmt.Errorf("GitHub Actions fragment %q is not a supported native selector", target.Fragment)
|
|
134
|
+
}
|
|
135
|
+
if q := strings.TrimSpace(target.Query.Get("query")); q != "" {
|
|
136
|
+
return "", fmt.Errorf("GitHub Actions UI filter query %q is not yet a supported native filter", q)
|
|
137
|
+
}
|
|
138
|
+
workflows, workflowTotal, _, err := fetchGitHubWorkflowPageForRepo(ctx, client, target.Owner, target.Repo, "", 4)
|
|
139
|
+
if err != nil {
|
|
140
|
+
return "", err
|
|
141
|
+
}
|
|
142
|
+
runs, runTotal, links, err := fetchGitHubRunPage(ctx, client, target, "", 8)
|
|
143
|
+
if err != nil {
|
|
144
|
+
return "", err
|
|
145
|
+
}
|
|
146
|
+
return renderGitHubActionsOverview(target, workflows, workflowTotal, runs, runTotal, links), nil
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func readGitHubWorkflows(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
150
|
+
if target.Fragment != "" {
|
|
151
|
+
return "", fmt.Errorf("GitHub workflows fragment %q is not a supported native selector", target.Fragment)
|
|
152
|
+
}
|
|
153
|
+
workflows, total, links, err := fetchGitHubWorkflowPageForRepo(ctx, client, target.Owner, target.Repo, target.Query.Get("page"), githubPageableListSize)
|
|
154
|
+
if err != nil {
|
|
155
|
+
return "", err
|
|
156
|
+
}
|
|
157
|
+
return renderGitHubWorkflows(target, workflows, total, links), nil
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func fetchGitHubWorkflowPageForRepo(ctx context.Context, client *GitHubClient, owner, repo, page string, perPage int) ([]githubWorkflow, int, GitHubLinkRelations, error) {
|
|
161
|
+
if perPage <= 0 {
|
|
162
|
+
perPage = githubPageableListSize
|
|
163
|
+
}
|
|
164
|
+
query := url.Values{"per_page": []string{strconv.Itoa(perPage)}}
|
|
165
|
+
if strings.TrimSpace(page) != "" {
|
|
166
|
+
if parsed, err := strconv.Atoi(page); err != nil || parsed <= 0 {
|
|
167
|
+
return nil, 0, nil, fmt.Errorf("invalid GitHub Actions page %q", page)
|
|
168
|
+
}
|
|
169
|
+
query.Set("page", page)
|
|
170
|
+
}
|
|
171
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/actions/workflows?%s", url.PathEscape(owner), url.PathEscape(repo), query.Encode())
|
|
172
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
173
|
+
if err != nil {
|
|
174
|
+
return nil, 0, nil, err
|
|
175
|
+
}
|
|
176
|
+
var pageData githubWorkflowList
|
|
177
|
+
if err := json.Unmarshal(resp.Body, &pageData); err != nil {
|
|
178
|
+
return nil, 0, nil, fmt.Errorf("decode GitHub workflows: %w", err)
|
|
179
|
+
}
|
|
180
|
+
return pageData.Workflows, pageData.TotalCount, resp.Links(), nil
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func readGitHubWorkflow(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
184
|
+
if target.Fragment != "" {
|
|
185
|
+
return "", fmt.Errorf("GitHub workflow fragment %q is not a supported native selector", target.Fragment)
|
|
186
|
+
}
|
|
187
|
+
if q := strings.TrimSpace(target.Query.Get("query")); q != "" {
|
|
188
|
+
return "", fmt.Errorf("GitHub workflow UI filter query %q is not yet a supported native filter", q)
|
|
189
|
+
}
|
|
190
|
+
workflowEndpoint := fmt.Sprintf("/repos/%s/%s/actions/workflows/%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), url.PathEscape(target.Name))
|
|
191
|
+
resp, err := client.REST(ctx, http.MethodGet, workflowEndpoint, "application/vnd.github+json")
|
|
192
|
+
if err != nil {
|
|
193
|
+
return "", err
|
|
194
|
+
}
|
|
195
|
+
var workflow githubWorkflow
|
|
196
|
+
if err := json.Unmarshal(resp.Body, &workflow); err != nil {
|
|
197
|
+
return "", fmt.Errorf("decode GitHub workflow: %w", err)
|
|
198
|
+
}
|
|
199
|
+
runs, total, links, err := fetchGitHubRunPage(ctx, client, target, target.Name, githubPageableListSize)
|
|
200
|
+
if err != nil {
|
|
201
|
+
return "", err
|
|
202
|
+
}
|
|
203
|
+
return renderGitHubWorkflow(target, workflow, runs, total, links), nil
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
func fetchGitHubRunPage(ctx context.Context, client *GitHubClient, target *GitHubTarget, workflow string, perPage int) ([]githubActionsRun, int, GitHubLinkRelations, error) {
|
|
207
|
+
query := copySelectedQuery(target.Query, []string{"actor", "branch", "event", "status", "created", "exclude_pull_requests", "check_suite_id", "head_sha", "page"})
|
|
208
|
+
if perPage <= 0 {
|
|
209
|
+
perPage = githubPageableListSize
|
|
210
|
+
}
|
|
211
|
+
query.Set("per_page", strconv.Itoa(perPage))
|
|
212
|
+
if rawPage := query.Get("page"); rawPage != "" {
|
|
213
|
+
if parsed, err := strconv.Atoi(rawPage); err != nil || parsed <= 0 {
|
|
214
|
+
return nil, 0, nil, fmt.Errorf("invalid GitHub Actions page %q", rawPage)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/actions/runs", url.PathEscape(target.Owner), url.PathEscape(target.Repo))
|
|
218
|
+
if workflow != "" {
|
|
219
|
+
endpoint = fmt.Sprintf("/repos/%s/%s/actions/workflows/%s/runs", url.PathEscape(target.Owner), url.PathEscape(target.Repo), url.PathEscape(workflow))
|
|
220
|
+
}
|
|
221
|
+
endpoint += "?" + query.Encode()
|
|
222
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
223
|
+
if err != nil {
|
|
224
|
+
return nil, 0, nil, err
|
|
225
|
+
}
|
|
226
|
+
var pageData githubWorkflowRunsPage
|
|
227
|
+
if err := json.Unmarshal(resp.Body, &pageData); err != nil {
|
|
228
|
+
return nil, 0, nil, fmt.Errorf("decode GitHub workflow runs: %w", err)
|
|
229
|
+
}
|
|
230
|
+
return pageData.WorkflowRuns, pageData.TotalCount, resp.Links(), nil
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
func readGitHubActionsRun(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
234
|
+
if target.Fragment != "" {
|
|
235
|
+
return "", fmt.Errorf("GitHub Actions run fragment %q is not a supported native selector", target.Fragment)
|
|
236
|
+
}
|
|
237
|
+
run, err := fetchGitHubActionsRun(ctx, client, target, target.RunID)
|
|
238
|
+
if err != nil {
|
|
239
|
+
return "", err
|
|
240
|
+
}
|
|
241
|
+
jobs, jobsReported, err := fetchGitHubActionsRunJobs(ctx, client, target, target.RunID)
|
|
242
|
+
if err != nil {
|
|
243
|
+
return "", err
|
|
244
|
+
}
|
|
245
|
+
artifacts, artifactsReported, artifactsMore, err := fetchGitHubActionsRunArtifactsPage(ctx, client, target, target.RunID)
|
|
246
|
+
if err != nil {
|
|
247
|
+
return "", err
|
|
248
|
+
}
|
|
249
|
+
availability := githubActionsRunAvailability{
|
|
250
|
+
JobsReported: jobsReported,
|
|
251
|
+
ArtifactsReported: artifactsReported,
|
|
252
|
+
ArtifactsProviderMore: artifactsMore,
|
|
253
|
+
}
|
|
254
|
+
return renderGitHubActionsRun(target, run, jobs, artifacts, availability), nil
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
func fetchGitHubActionsRun(ctx context.Context, client *GitHubClient, target *GitHubTarget, runID int64) (githubActionsRun, error) {
|
|
258
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/actions/runs/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), runID)
|
|
259
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
260
|
+
if err != nil {
|
|
261
|
+
return githubActionsRun{}, err
|
|
262
|
+
}
|
|
263
|
+
var run githubActionsRun
|
|
264
|
+
if err := json.Unmarshal(resp.Body, &run); err != nil {
|
|
265
|
+
return githubActionsRun{}, fmt.Errorf("decode GitHub Actions run: %w", err)
|
|
266
|
+
}
|
|
267
|
+
return run, nil
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
func fetchGitHubActionsRunJobs(ctx context.Context, client *GitHubClient, target *GitHubTarget, runID int64) ([]githubActionsJob, int, error) {
|
|
271
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/actions/runs/%d/jobs?filter=latest&per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), runID)
|
|
272
|
+
pages, err := client.RESTPages(ctx, endpoint, "application/vnd.github+json")
|
|
273
|
+
if err != nil {
|
|
274
|
+
return nil, 0, err
|
|
275
|
+
}
|
|
276
|
+
jobs := []githubActionsJob{}
|
|
277
|
+
total := 0
|
|
278
|
+
for _, page := range pages {
|
|
279
|
+
var batch githubActionsJobsPage
|
|
280
|
+
if err := json.Unmarshal(page.Body, &batch); err != nil {
|
|
281
|
+
return nil, 0, fmt.Errorf("decode GitHub Actions jobs: %w", err)
|
|
282
|
+
}
|
|
283
|
+
if batch.TotalCount > total {
|
|
284
|
+
total = batch.TotalCount
|
|
285
|
+
}
|
|
286
|
+
jobs = append(jobs, batch.Jobs...)
|
|
287
|
+
}
|
|
288
|
+
return jobs, total, nil
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
func fetchGitHubActionsRunArtifactsPage(ctx context.Context, client *GitHubClient, target *GitHubTarget, runID int64) ([]githubActionsArtifact, int, bool, error) {
|
|
292
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/actions/runs/%d/artifacts?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), runID)
|
|
293
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
294
|
+
if err != nil {
|
|
295
|
+
return nil, 0, false, err
|
|
296
|
+
}
|
|
297
|
+
var batch githubActionsArtifactsPage
|
|
298
|
+
if err := json.Unmarshal(resp.Body, &batch); err != nil {
|
|
299
|
+
return nil, 0, false, fmt.Errorf("decode GitHub Actions artifacts: %w", err)
|
|
300
|
+
}
|
|
301
|
+
return batch.Artifacts, batch.TotalCount, strings.TrimSpace(resp.Links()["next"]) != "", nil
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
func readGitHubActionsJob(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
305
|
+
if target.Fragment != "" {
|
|
306
|
+
return "", fmt.Errorf("GitHub Actions job step fragment %q is not a proven native selector", target.Fragment)
|
|
307
|
+
}
|
|
308
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/actions/jobs/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.JobID)
|
|
309
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
310
|
+
if err != nil {
|
|
311
|
+
return "", err
|
|
312
|
+
}
|
|
313
|
+
var job githubActionsJob
|
|
314
|
+
if err := json.Unmarshal(resp.Body, &job); err != nil {
|
|
315
|
+
return "", fmt.Errorf("decode GitHub Actions job: %w", err)
|
|
316
|
+
}
|
|
317
|
+
if job.RunID != 0 && job.RunID != target.RunID {
|
|
318
|
+
return "", fmt.Errorf("GitHub Actions job %d belongs to run %d, not selected run %d", target.JobID, job.RunID, target.RunID)
|
|
319
|
+
}
|
|
320
|
+
log, err := fetchGitHubActionsJobLog(ctx, client, target)
|
|
321
|
+
if err != nil {
|
|
322
|
+
return "", err
|
|
323
|
+
}
|
|
324
|
+
return renderGitHubActionsJob(target, job, log), nil
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
func fetchGitHubActionsJobLog(ctx context.Context, client *GitHubClient, target *GitHubTarget) (githubJobLog, error) {
|
|
328
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.JobID)
|
|
329
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
330
|
+
if err != nil {
|
|
331
|
+
var ghErr *GitHubError
|
|
332
|
+
if asGitHubError(err, &ghErr) && (ghErr.Kind == GitHubErrorNotFound || ghErr.Kind == GitHubErrorGone) {
|
|
333
|
+
return githubJobLog{Unavailable: fmt.Sprintf("GitHub did not make this job log available (HTTP %d). It may be expired, deleted, or not yet generated.", ghErr.StatusCode)}, nil
|
|
334
|
+
}
|
|
335
|
+
return githubJobLog{}, err
|
|
336
|
+
}
|
|
337
|
+
if resp.TooLarge {
|
|
338
|
+
return githubJobLog{TooLarge: true}, nil
|
|
339
|
+
}
|
|
340
|
+
text, err := decodeGitHubJobLog(resp.Body)
|
|
341
|
+
if err != nil {
|
|
342
|
+
return githubJobLog{}, err
|
|
343
|
+
}
|
|
344
|
+
return githubJobLog{Text: text}, nil
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
func decodeGitHubJobLog(body []byte) (string, error) {
|
|
348
|
+
if len(body) >= 4 && bytes.Equal(body[:4], []byte{'P', 'K', 3, 4}) {
|
|
349
|
+
reader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
|
350
|
+
if err != nil {
|
|
351
|
+
return "", fmt.Errorf("decode GitHub Actions job log archive: %w", err)
|
|
352
|
+
}
|
|
353
|
+
files := append([]*zip.File(nil), reader.File...)
|
|
354
|
+
sort.SliceStable(files, func(i, j int) bool { return files[i].Name < files[j].Name })
|
|
355
|
+
parts := []string{}
|
|
356
|
+
total := int64(0)
|
|
357
|
+
for _, file := range files {
|
|
358
|
+
if file.FileInfo().IsDir() {
|
|
359
|
+
continue
|
|
360
|
+
}
|
|
361
|
+
total += int64(file.UncompressedSize64)
|
|
362
|
+
if total > githubBlobMaxBytes {
|
|
363
|
+
return "", fmt.Errorf("GitHub Actions job log archive exceeds the supported %d-byte read limit", githubBlobMaxBytes)
|
|
364
|
+
}
|
|
365
|
+
rc, err := file.Open()
|
|
366
|
+
if err != nil {
|
|
367
|
+
return "", fmt.Errorf("open GitHub Actions job log archive entry: %w", err)
|
|
368
|
+
}
|
|
369
|
+
data, readErr := io.ReadAll(io.LimitReader(rc, githubBlobMaxBytes+1))
|
|
370
|
+
_ = rc.Close()
|
|
371
|
+
if readErr != nil {
|
|
372
|
+
return "", fmt.Errorf("read GitHub Actions job log archive entry: %w", readErr)
|
|
373
|
+
}
|
|
374
|
+
if !utf8.Valid(data) {
|
|
375
|
+
return "", fmt.Errorf("GitHub Actions job log archive entry %q is not text", file.Name)
|
|
376
|
+
}
|
|
377
|
+
if len(files) > 1 {
|
|
378
|
+
parts = append(parts, "===== "+file.Name+" =====\n"+string(data))
|
|
379
|
+
} else {
|
|
380
|
+
parts = append(parts, string(data))
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if len(parts) == 0 {
|
|
384
|
+
return "", fmt.Errorf("GitHub Actions job log archive contained no files")
|
|
385
|
+
}
|
|
386
|
+
return strings.Join(parts, "\n"), nil
|
|
387
|
+
}
|
|
388
|
+
if !utf8.Valid(body) {
|
|
389
|
+
return "", fmt.Errorf("GitHub Actions job log response is not UTF-8 text")
|
|
390
|
+
}
|
|
391
|
+
return string(body), nil
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
func renderGitHubActionsOverview(target *GitHubTarget, workflows []githubWorkflow, workflowTotal int, runs []githubActionsRun, runTotal int, links GitHubLinkRelations) string {
|
|
395
|
+
return renderGitHubActionsOverviewWithLimits(target, workflows, workflowTotal, runs, runTotal, links, len(workflows), len(runs))
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
func renderGitHubActionsOverviewWithLimits(target *GitHubTarget, workflows []githubWorkflow, workflowTotal int, runs []githubActionsRun, runTotal int, links GitHubLinkRelations, workflowLimit, runLimit int) string {
|
|
399
|
+
workflowLimit = minInt(workflowLimit, len(workflows))
|
|
400
|
+
runLimit = minInt(runLimit, len(runs))
|
|
401
|
+
lines := []string{
|
|
402
|
+
"---",
|
|
403
|
+
"repository: " + yamlScalar(target.Owner+"/"+target.Repo),
|
|
404
|
+
"view: actions",
|
|
405
|
+
fmt.Sprintf("workflows_returned: %d", len(workflows)),
|
|
406
|
+
fmt.Sprintf("workflows_reported: %d", workflowTotal),
|
|
407
|
+
fmt.Sprintf("workflows_indexed: %d", workflowLimit),
|
|
408
|
+
fmt.Sprintf("workflows_local_omitted: %d", len(workflows)-workflowLimit),
|
|
409
|
+
fmt.Sprintf("runs_returned: %d", len(runs)),
|
|
410
|
+
fmt.Sprintf("runs_reported: %d", runTotal),
|
|
411
|
+
fmt.Sprintf("runs_indexed: %d", runLimit),
|
|
412
|
+
fmt.Sprintf("runs_local_omitted: %d", len(runs)-runLimit),
|
|
413
|
+
}
|
|
414
|
+
if workflowTotal > len(workflows) {
|
|
415
|
+
lines = append(lines, "workflows_provider_more_available: true")
|
|
416
|
+
}
|
|
417
|
+
if runTotal > len(runs) {
|
|
418
|
+
lines = append(lines, "runs_provider_more_available: true")
|
|
419
|
+
}
|
|
420
|
+
lines = append(lines, "---", "", "# Actions", "", "All workflows: "+actionsWorkflowsURL(target), "", "## Workflow index", "")
|
|
421
|
+
lines = append(lines, renderWorkflowList(target, workflows[:workflowLimit])...)
|
|
422
|
+
lines = append(lines, "", "## Recent run index", "")
|
|
423
|
+
lines = append(lines, renderActionsRunList(target, runs[:runLimit])...)
|
|
424
|
+
if nav := renderGitHubUIPageNavigation(target, links); len(nav) > 0 {
|
|
425
|
+
lines = append(lines, "", "## Run navigation", "")
|
|
426
|
+
lines = append(lines, nav...)
|
|
427
|
+
}
|
|
428
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
func renderGitHubWorkflows(target *GitHubTarget, workflows []githubWorkflow, total int, links GitHubLinkRelations) string {
|
|
432
|
+
limit := len(workflows)
|
|
433
|
+
lines := []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), "view: workflows", fmt.Sprintf("workflows_returned: %d", len(workflows)), fmt.Sprintf("workflows_reported: %d", total), fmt.Sprintf("workflows_indexed: %d", limit), fmt.Sprintf("workflows_local_omitted: %d", len(workflows)-limit), "---", "", "# Workflows", ""}
|
|
434
|
+
lines = append(lines, renderWorkflowList(target, workflows[:limit])...)
|
|
435
|
+
if nav := renderGitHubUIPageNavigation(target, links); len(nav) > 0 {
|
|
436
|
+
lines = append(lines, "", "## Navigation", "")
|
|
437
|
+
lines = append(lines, nav...)
|
|
438
|
+
}
|
|
439
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
func renderWorkflowList(target *GitHubTarget, workflows []githubWorkflow) []string {
|
|
443
|
+
if len(workflows) == 0 {
|
|
444
|
+
return []string{"_No workflows returned by GitHub._"}
|
|
445
|
+
}
|
|
446
|
+
lines := []string{}
|
|
447
|
+
for _, workflow := range workflows {
|
|
448
|
+
href := actionsWorkflowURL(target, workflow)
|
|
449
|
+
name := workflow.Name
|
|
450
|
+
if name == "" {
|
|
451
|
+
name = workflow.Path
|
|
452
|
+
}
|
|
453
|
+
name = actionsListLabel(name)
|
|
454
|
+
line := "- [" + escapeMarkdownLinkText(name) + "](" + href + ")"
|
|
455
|
+
meta := []string{}
|
|
456
|
+
if workflow.State != "" {
|
|
457
|
+
meta = append(meta, workflow.State)
|
|
458
|
+
}
|
|
459
|
+
if workflow.Path != "" {
|
|
460
|
+
path, truncated := githubOverviewInlinePreview(workflow.Path, 140)
|
|
461
|
+
if truncated {
|
|
462
|
+
path += "…"
|
|
463
|
+
}
|
|
464
|
+
meta = append(meta, "`"+path+"`")
|
|
465
|
+
}
|
|
466
|
+
if workflow.HTMLURL != "" && workflow.Path != "" {
|
|
467
|
+
meta = append(meta, "source "+workflow.HTMLURL)
|
|
468
|
+
}
|
|
469
|
+
if len(meta) > 0 {
|
|
470
|
+
line += " — " + strings.Join(meta, " · ")
|
|
471
|
+
}
|
|
472
|
+
lines = append(lines, line)
|
|
473
|
+
}
|
|
474
|
+
return lines
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
func renderGitHubWorkflow(target *GitHubTarget, workflow githubWorkflow, runs []githubActionsRun, total int, links GitHubLinkRelations) string {
|
|
478
|
+
return renderGitHubWorkflowWithLimit(target, workflow, runs, total, links, len(runs))
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
func renderGitHubWorkflowWithLimit(target *GitHubTarget, workflow githubWorkflow, runs []githubActionsRun, total int, links GitHubLinkRelations, runLimit int) string {
|
|
482
|
+
runLimit = minInt(runLimit, len(runs))
|
|
483
|
+
name := actionsListLabel(workflow.Name)
|
|
484
|
+
path, pathTruncated := githubOverviewInlinePreview(workflow.Path, 180)
|
|
485
|
+
if pathTruncated {
|
|
486
|
+
path += "…"
|
|
487
|
+
}
|
|
488
|
+
lines := []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), fmt.Sprintf("workflow_id: %d", workflow.ID), "name: " + yamlScalar(name), "state: " + yamlScalar(workflow.State), "path: " + yamlScalar(path), fmt.Sprintf("runs_returned: %d", len(runs)), fmt.Sprintf("runs_reported: %d", total), fmt.Sprintf("runs_indexed: %d", runLimit), fmt.Sprintf("runs_local_omitted: %d", len(runs)-runLimit)}
|
|
489
|
+
lines = append(lines, "url: "+yamlScalar(actionsWorkflowURL(target, workflow)))
|
|
490
|
+
if workflow.HTMLURL != "" && workflow.Path != "" {
|
|
491
|
+
lines = append(lines, "source_url: "+yamlScalar(workflow.HTMLURL))
|
|
492
|
+
}
|
|
493
|
+
lines = append(lines, "---", "", "# Workflow: "+name, "", "## Runs", "")
|
|
494
|
+
lines = append(lines, renderActionsRunList(target, runs[:runLimit])...)
|
|
495
|
+
if nav := renderGitHubUIPageNavigation(target, links); len(nav) > 0 {
|
|
496
|
+
lines = append(lines, "", "## Navigation", "")
|
|
497
|
+
lines = append(lines, nav...)
|
|
498
|
+
}
|
|
499
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
func renderActionsRunList(target *GitHubTarget, runs []githubActionsRun) []string {
|
|
503
|
+
if len(runs) == 0 {
|
|
504
|
+
return []string{"_No workflow runs returned by GitHub._"}
|
|
505
|
+
}
|
|
506
|
+
lines := []string{}
|
|
507
|
+
for _, run := range runs {
|
|
508
|
+
href := run.HTMLURL
|
|
509
|
+
if href == "" {
|
|
510
|
+
href = fmt.Sprintf("https://github.com/%s/%s/actions/runs/%d", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), run.ID)
|
|
511
|
+
}
|
|
512
|
+
name := run.DisplayTitle
|
|
513
|
+
if name == "" {
|
|
514
|
+
name = run.Name
|
|
515
|
+
}
|
|
516
|
+
if name == "" {
|
|
517
|
+
name = fmt.Sprintf("Run %d", run.ID)
|
|
518
|
+
}
|
|
519
|
+
name = actionsListLabel(name)
|
|
520
|
+
meta := []string{}
|
|
521
|
+
if run.Status != "" {
|
|
522
|
+
meta = append(meta, run.Status)
|
|
523
|
+
}
|
|
524
|
+
if run.Conclusion != "" {
|
|
525
|
+
meta = append(meta, run.Conclusion)
|
|
526
|
+
}
|
|
527
|
+
if run.Event != "" {
|
|
528
|
+
meta = append(meta, run.Event)
|
|
529
|
+
}
|
|
530
|
+
if run.HeadBranch != "" {
|
|
531
|
+
branch, truncated := githubOverviewInlinePreview(run.HeadBranch, 100)
|
|
532
|
+
if truncated {
|
|
533
|
+
branch += "…"
|
|
534
|
+
}
|
|
535
|
+
meta = append(meta, branch)
|
|
536
|
+
}
|
|
537
|
+
line := fmt.Sprintf("- [%s](%s)", escapeMarkdownLinkText(name), href)
|
|
538
|
+
if len(meta) > 0 {
|
|
539
|
+
line += " — " + strings.Join(meta, " · ")
|
|
540
|
+
}
|
|
541
|
+
lines = append(lines, line)
|
|
542
|
+
}
|
|
543
|
+
return lines
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
func actionsListLabel(value string) string {
|
|
547
|
+
preview, truncated := githubOverviewInlinePreview(value, 180)
|
|
548
|
+
if truncated {
|
|
549
|
+
return preview + "…"
|
|
550
|
+
}
|
|
551
|
+
return preview
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
func actionsWorkflowsURL(target *GitHubTarget) string {
|
|
555
|
+
return fmt.Sprintf("https://github.com/%s/%s/actions/workflows", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo))
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
func actionsWorkflowURL(target *GitHubTarget, workflow githubWorkflow) string {
|
|
559
|
+
selector := strings.TrimSpace(workflow.Path)
|
|
560
|
+
if selector != "" {
|
|
561
|
+
selector = path.Base(selector)
|
|
562
|
+
}
|
|
563
|
+
if selector == "" && workflow.ID > 0 {
|
|
564
|
+
selector = strconv.FormatInt(workflow.ID, 10)
|
|
565
|
+
}
|
|
566
|
+
if selector == "" {
|
|
567
|
+
return actionsWorkflowsURL(target)
|
|
568
|
+
}
|
|
569
|
+
return fmt.Sprintf("https://github.com/%s/%s/actions/workflows/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), url.PathEscape(selector))
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
func renderGitHubActionsRun(target *GitHubTarget, run githubActionsRun, jobs []githubActionsJob, artifacts []githubActionsArtifact, availability githubActionsRunAvailability) string {
|
|
573
|
+
orderedJobs := append([]githubActionsJob(nil), jobs...)
|
|
574
|
+
sortGitHubActionsJobs(orderedJobs)
|
|
575
|
+
jobLimit := minInt(18, len(orderedJobs))
|
|
576
|
+
artifactLimit := minInt(8, len(artifacts))
|
|
577
|
+
for {
|
|
578
|
+
out := renderGitHubActionsRunWithLimits(target, run, orderedJobs, artifacts, availability, jobLimit, artifactLimit)
|
|
579
|
+
if githubOverviewFits(out) {
|
|
580
|
+
return out
|
|
581
|
+
}
|
|
582
|
+
switch {
|
|
583
|
+
case artifactLimit > 1:
|
|
584
|
+
artifactLimit--
|
|
585
|
+
case jobLimit > 1:
|
|
586
|
+
jobLimit--
|
|
587
|
+
default:
|
|
588
|
+
return out
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
func renderGitHubActionsRunWithLimits(target *GitHubTarget, run githubActionsRun, jobs []githubActionsJob, artifacts []githubActionsArtifact, availability githubActionsRunAvailability, jobLimit, artifactLimit int) string {
|
|
594
|
+
jobLimit = minInt(jobLimit, len(jobs))
|
|
595
|
+
artifactLimit = minInt(artifactLimit, len(artifacts))
|
|
596
|
+
jobsReported := availability.JobsReported
|
|
597
|
+
if jobsReported < len(jobs) {
|
|
598
|
+
jobsReported = len(jobs)
|
|
599
|
+
}
|
|
600
|
+
artifactsReported := availability.ArtifactsReported
|
|
601
|
+
if artifactsReported < len(artifacts) {
|
|
602
|
+
artifactsReported = len(artifacts)
|
|
603
|
+
}
|
|
604
|
+
jobStatusCounts, jobConclusionCounts := githubActionsJobCounts(jobs)
|
|
605
|
+
expiredArtifacts := 0
|
|
606
|
+
for _, artifact := range artifacts {
|
|
607
|
+
if artifact.Expired {
|
|
608
|
+
expiredArtifacts++
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
lines := []string{
|
|
612
|
+
"---",
|
|
613
|
+
"repository: " + yamlScalar(target.Owner+"/"+target.Repo),
|
|
614
|
+
fmt.Sprintf("run_id: %d", run.ID),
|
|
615
|
+
fmt.Sprintf("run_number: %d", run.RunNumber),
|
|
616
|
+
fmt.Sprintf("attempt: %d", run.RunAttempt),
|
|
617
|
+
"status: " + yamlScalar(run.Status),
|
|
618
|
+
}
|
|
619
|
+
if run.Conclusion != "" {
|
|
620
|
+
lines = append(lines, "conclusion: "+yamlScalar(run.Conclusion))
|
|
621
|
+
}
|
|
622
|
+
if run.Event != "" {
|
|
623
|
+
lines = append(lines, "event: "+yamlScalar(run.Event))
|
|
624
|
+
}
|
|
625
|
+
if run.HeadBranch != "" {
|
|
626
|
+
lines = append(lines, "head_branch: "+yamlScalar(run.HeadBranch))
|
|
627
|
+
}
|
|
628
|
+
if run.HeadSHA != "" {
|
|
629
|
+
lines = append(lines, "head_sha: "+yamlScalar(run.HeadSHA))
|
|
630
|
+
}
|
|
631
|
+
if run.Actor.Login != "" {
|
|
632
|
+
lines = append(lines, "actor: "+yamlScalar("@"+run.Actor.Login))
|
|
633
|
+
}
|
|
634
|
+
for _, item := range []struct{ key, value string }{{"started", run.RunStartedAt}, {"created", run.CreatedAt}, {"updated", run.UpdatedAt}} {
|
|
635
|
+
if item.value != "" {
|
|
636
|
+
lines = append(lines, item.key+": "+yamlScalar(item.value))
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if run.HTMLURL != "" {
|
|
640
|
+
lines = append(lines, "url: "+yamlScalar(run.HTMLURL))
|
|
641
|
+
}
|
|
642
|
+
lines = append(lines,
|
|
643
|
+
fmt.Sprintf("jobs_returned: %d", len(jobs)),
|
|
644
|
+
fmt.Sprintf("jobs_reported: %d", jobsReported),
|
|
645
|
+
fmt.Sprintf("jobs_indexed: %d", jobLimit),
|
|
646
|
+
fmt.Sprintf("jobs_local_omitted: %d", len(jobs)-jobLimit),
|
|
647
|
+
"job_status_counts: "+jsonMapScalar(jobStatusCounts),
|
|
648
|
+
"job_conclusion_counts: "+jsonMapScalar(jobConclusionCounts),
|
|
649
|
+
fmt.Sprintf("artifacts_returned: %d", len(artifacts)),
|
|
650
|
+
fmt.Sprintf("artifacts_reported: %d", artifactsReported),
|
|
651
|
+
fmt.Sprintf("artifacts_indexed: %d", artifactLimit),
|
|
652
|
+
fmt.Sprintf("artifacts_local_omitted: %d", len(artifacts)-artifactLimit),
|
|
653
|
+
fmt.Sprintf("artifacts_expired_returned: %d", expiredArtifacts),
|
|
654
|
+
)
|
|
655
|
+
if jobsReported > len(jobs) {
|
|
656
|
+
lines = append(lines, "jobs_provider_complete: false")
|
|
657
|
+
}
|
|
658
|
+
if availability.ArtifactsProviderMore {
|
|
659
|
+
lines = append(lines, "artifacts_provider_more_available: true")
|
|
660
|
+
} else if artifactsReported > len(artifacts) {
|
|
661
|
+
lines = append(lines, "artifacts_provider_complete: false")
|
|
662
|
+
}
|
|
663
|
+
lines = append(lines, "---", "")
|
|
664
|
+
title := run.DisplayTitle
|
|
665
|
+
if title == "" {
|
|
666
|
+
title = run.Name
|
|
667
|
+
}
|
|
668
|
+
if title == "" {
|
|
669
|
+
title = fmt.Sprintf("Run %d", run.ID)
|
|
670
|
+
}
|
|
671
|
+
lines = append(lines, "# "+actionsListLabel(title), "", "## Rollup", "")
|
|
672
|
+
lines = append(lines, "- Job statuses: "+formatStringCounts(jobStatusCounts))
|
|
673
|
+
lines = append(lines, "- Job conclusions: "+formatStringCounts(jobConclusionCounts))
|
|
674
|
+
lines = append(lines, fmt.Sprintf("- Artifacts reported: %d (%d returned on fetched provider page, %d expired)", artifactsReported, len(artifacts), expiredArtifacts))
|
|
675
|
+
|
|
676
|
+
lines = append(lines, "", "## Job index", "")
|
|
677
|
+
if len(jobs) == 0 {
|
|
678
|
+
lines = append(lines, "_No jobs returned by GitHub._")
|
|
679
|
+
}
|
|
680
|
+
for _, job := range jobs[:jobLimit] {
|
|
681
|
+
lines = append(lines, renderGitHubActionsJobIndex(target, run.ID, job))
|
|
682
|
+
}
|
|
683
|
+
if note := githubLocalOmissionNote("jobs", len(jobs)-jobLimit); note != "" {
|
|
684
|
+
lines = append(lines, "", note)
|
|
685
|
+
}
|
|
686
|
+
if jobsReported > len(jobs) {
|
|
687
|
+
lines = append(lines, "", "> GitHub reported more latest-attempt jobs than were returned by the provider pages; this provider-incomplete state is separate from local overview omission.")
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
lines = append(lines, "", "## Artifact index", "")
|
|
691
|
+
if len(artifacts) == 0 {
|
|
692
|
+
lines = append(lines, "_No artifacts returned by GitHub on the fetched provider page._")
|
|
693
|
+
}
|
|
694
|
+
for _, artifact := range artifacts[:artifactLimit] {
|
|
695
|
+
lines = append(lines, renderGitHubActionsArtifactIndex(artifact))
|
|
696
|
+
}
|
|
697
|
+
if note := githubLocalOmissionNote("artifacts returned on this provider page", len(artifacts)-artifactLimit); note != "" {
|
|
698
|
+
lines = append(lines, "", note)
|
|
699
|
+
}
|
|
700
|
+
if availability.ArtifactsProviderMore {
|
|
701
|
+
lines = append(lines, "", "> GitHub has more artifacts beyond the provider page fetched for this overview.")
|
|
702
|
+
} else if artifactsReported > len(artifacts) {
|
|
703
|
+
lines = append(lines, "", "> GitHub reports more artifacts than were returned by the fetched provider data; this provider-incomplete state is separate from local overview omission.")
|
|
704
|
+
}
|
|
705
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
func renderGitHubActionsJobIndex(target *GitHubTarget, runID int64, job githubActionsJob) string {
|
|
709
|
+
href := job.HTMLURL
|
|
710
|
+
if href == "" {
|
|
711
|
+
href = fmt.Sprintf("https://github.com/%s/%s/actions/runs/%d/job/%d", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), runID, job.ID)
|
|
712
|
+
}
|
|
713
|
+
name := actionsListLabel(job.Name)
|
|
714
|
+
if name == "" {
|
|
715
|
+
name = fmt.Sprintf("Job %d", job.ID)
|
|
716
|
+
}
|
|
717
|
+
meta := []string{fmt.Sprintf("id %d", job.ID)}
|
|
718
|
+
if job.Status != "" {
|
|
719
|
+
meta = append(meta, job.Status)
|
|
720
|
+
}
|
|
721
|
+
if job.Conclusion != "" {
|
|
722
|
+
meta = append(meta, job.Conclusion)
|
|
723
|
+
}
|
|
724
|
+
return fmt.Sprintf("- [%s](%s) — %s", escapeMarkdownLinkText(name), href, strings.Join(meta, " · "))
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
func renderGitHubActionsArtifactIndex(artifact githubActionsArtifact) string {
|
|
728
|
+
name := actionsListLabel(artifact.Name)
|
|
729
|
+
if name == "" {
|
|
730
|
+
name = fmt.Sprintf("Artifact %d", artifact.ID)
|
|
731
|
+
}
|
|
732
|
+
line := fmt.Sprintf("- **%s** — id %d · %d bytes", name, artifact.ID, artifact.SizeInBytes)
|
|
733
|
+
if artifact.Expired {
|
|
734
|
+
line += " · expired"
|
|
735
|
+
} else if artifact.ExpiresAt != "" {
|
|
736
|
+
line += " · expires " + artifact.ExpiresAt
|
|
737
|
+
}
|
|
738
|
+
if artifact.ArchiveDownloadURL != "" {
|
|
739
|
+
line += " · archive API: " + artifact.ArchiveDownloadURL
|
|
740
|
+
}
|
|
741
|
+
return line
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
func sortGitHubActionsJobs(jobs []githubActionsJob) {
|
|
745
|
+
sort.SliceStable(jobs, func(i, j int) bool {
|
|
746
|
+
pi, pj := githubActionsJobPriority(jobs[i]), githubActionsJobPriority(jobs[j])
|
|
747
|
+
if pi != pj {
|
|
748
|
+
return pi < pj
|
|
749
|
+
}
|
|
750
|
+
if jobs[i].Name == jobs[j].Name {
|
|
751
|
+
return jobs[i].ID < jobs[j].ID
|
|
752
|
+
}
|
|
753
|
+
return jobs[i].Name < jobs[j].Name
|
|
754
|
+
})
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
func githubActionsJobPriority(job githubActionsJob) int {
|
|
758
|
+
conclusion := strings.ToLower(strings.TrimSpace(job.Conclusion))
|
|
759
|
+
status := strings.ToLower(strings.TrimSpace(job.Status))
|
|
760
|
+
switch conclusion {
|
|
761
|
+
case "failure", "error", "cancelled", "canceled", "timed_out", "action_required", "startup_failure":
|
|
762
|
+
return 0
|
|
763
|
+
}
|
|
764
|
+
if status != "" && status != "completed" {
|
|
765
|
+
return 1
|
|
766
|
+
}
|
|
767
|
+
if conclusion == "" {
|
|
768
|
+
return 1
|
|
769
|
+
}
|
|
770
|
+
if conclusion == "success" || conclusion == "neutral" || conclusion == "skipped" || conclusion == "stale" {
|
|
771
|
+
return 2
|
|
772
|
+
}
|
|
773
|
+
return 0
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
func githubActionsJobCounts(jobs []githubActionsJob) (map[string]int, map[string]int) {
|
|
777
|
+
statuses := map[string]int{}
|
|
778
|
+
conclusions := map[string]int{}
|
|
779
|
+
for _, job := range jobs {
|
|
780
|
+
status := strings.TrimSpace(job.Status)
|
|
781
|
+
if status == "" {
|
|
782
|
+
status = "unknown"
|
|
783
|
+
}
|
|
784
|
+
statuses[status]++
|
|
785
|
+
conclusion := strings.TrimSpace(job.Conclusion)
|
|
786
|
+
if conclusion == "" {
|
|
787
|
+
conclusion = "none"
|
|
788
|
+
}
|
|
789
|
+
conclusions[conclusion]++
|
|
790
|
+
}
|
|
791
|
+
return statuses, conclusions
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
func renderGitHubActionsJob(target *GitHubTarget, job githubActionsJob, log githubJobLog) string {
|
|
795
|
+
logBudget := 1800
|
|
796
|
+
for {
|
|
797
|
+
out := renderGitHubActionsJobWithLogBudget(target, job, log, logBudget)
|
|
798
|
+
if githubOverviewFits(out) || log.Text == "" || logBudget <= 400 {
|
|
799
|
+
return out
|
|
800
|
+
}
|
|
801
|
+
logBudget -= 200
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
func renderGitHubActionsJobWithLogBudget(target *GitHubTarget, job githubActionsJob, log githubJobLog, logBudget int) string {
|
|
806
|
+
preview := githubJobLogPreview{}
|
|
807
|
+
if log.Text != "" {
|
|
808
|
+
preview = buildGitHubJobLogPreview(log.Text, job, logBudget)
|
|
809
|
+
}
|
|
810
|
+
lines := []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), fmt.Sprintf("run_id: %d", target.RunID), fmt.Sprintf("job_id: %d", target.JobID), "name: " + yamlScalar(job.Name), "status: " + yamlScalar(job.Status)}
|
|
811
|
+
if job.Conclusion != "" {
|
|
812
|
+
lines = append(lines, "conclusion: "+yamlScalar(job.Conclusion))
|
|
813
|
+
}
|
|
814
|
+
if job.HeadSHA != "" {
|
|
815
|
+
lines = append(lines, "head_sha: "+yamlScalar(job.HeadSHA))
|
|
816
|
+
}
|
|
817
|
+
if job.RunAttempt > 0 {
|
|
818
|
+
lines = append(lines, fmt.Sprintf("attempt: %d", job.RunAttempt))
|
|
819
|
+
}
|
|
820
|
+
if job.HTMLURL != "" {
|
|
821
|
+
lines = append(lines, "url: "+yamlScalar(job.HTMLURL))
|
|
822
|
+
}
|
|
823
|
+
if log.Text != "" {
|
|
824
|
+
lines = append(lines,
|
|
825
|
+
"log_preview_strategy: "+yamlScalar(preview.Strategy),
|
|
826
|
+
fmt.Sprintf("log_preview_truncated: %t", preview.Truncated),
|
|
827
|
+
fmt.Sprintf("log_lines_total: %d", preview.LinesTotal),
|
|
828
|
+
fmt.Sprintf("log_preview_lines_output: %d", preview.LinesShown),
|
|
829
|
+
)
|
|
830
|
+
}
|
|
831
|
+
lines = append(lines, "---", "", "# Job: "+job.Name, "", "## Steps", "")
|
|
832
|
+
if len(job.Steps) == 0 {
|
|
833
|
+
lines = append(lines, "_No structured steps returned by GitHub._")
|
|
834
|
+
}
|
|
835
|
+
for _, step := range job.Steps {
|
|
836
|
+
meta := []string{}
|
|
837
|
+
if step.Status != "" {
|
|
838
|
+
meta = append(meta, step.Status)
|
|
839
|
+
}
|
|
840
|
+
if step.Conclusion != "" {
|
|
841
|
+
meta = append(meta, step.Conclusion)
|
|
842
|
+
}
|
|
843
|
+
lines = append(lines, fmt.Sprintf("- %d. **%s** — %s", step.Number, actionsListLabel(step.Name), strings.Join(meta, " · ")))
|
|
844
|
+
}
|
|
845
|
+
lines = append(lines, "", "## Log preview", "")
|
|
846
|
+
switch {
|
|
847
|
+
case log.Unavailable != "":
|
|
848
|
+
lines = append(lines, "_"+log.Unavailable+"_")
|
|
849
|
+
case log.TooLarge:
|
|
850
|
+
lines = append(lines, fmt.Sprintf("_GitHub returned a job log larger than the supported %d-byte direct read limit._", githubBlobMaxBytes))
|
|
851
|
+
case log.Text == "":
|
|
852
|
+
lines = append(lines, "_GitHub returned an empty job log._")
|
|
853
|
+
default:
|
|
854
|
+
fence := markdownFenceForText(preview.Text)
|
|
855
|
+
lines = append(lines, "Preview strategy: `"+preview.Strategy+"`.", "", fence+"text", preview.Text, fence)
|
|
856
|
+
if preview.Truncated {
|
|
857
|
+
lines = append(lines, "", "> Job log preview locally truncated. Use the stable GitHub log endpoint below when you explicitly need the provider's complete retained log.")
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
lines = append(lines, "", "## Useful GitHub URLs", "")
|
|
861
|
+
if job.HTMLURL != "" {
|
|
862
|
+
lines = append(lines, "- Job page: "+job.HTMLURL)
|
|
863
|
+
}
|
|
864
|
+
lines = append(lines, "- Stable job log endpoint: "+githubActionsJobLogEndpointURL(target))
|
|
865
|
+
lines = append(lines, " - GitHub redirects this endpoint to a signed log download that expires after one minute when the retained log is available; webctx does not print that redirect location.")
|
|
866
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
func buildGitHubJobLogPreview(text string, job githubActionsJob, maxRunes int) githubJobLogPreview {
|
|
870
|
+
normalized := strings.ReplaceAll(text, "\r\n", "\n")
|
|
871
|
+
normalized = strings.TrimRight(normalized, "\n")
|
|
872
|
+
if normalized == "" {
|
|
873
|
+
return githubJobLogPreview{Strategy: "empty"}
|
|
874
|
+
}
|
|
875
|
+
lines := strings.Split(normalized, "\n")
|
|
876
|
+
if utf8.RuneCountInString(normalized) <= maxRunes {
|
|
877
|
+
return githubJobLogPreview{Text: normalized, Strategy: "full", LinesTotal: len(lines), LinesShown: len(lines)}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
failureLine, strategy := findGitHubJobFailureLogLine(lines, job)
|
|
881
|
+
var candidate string
|
|
882
|
+
if failureLine >= 0 {
|
|
883
|
+
start := failureLine - 5
|
|
884
|
+
if start < 0 {
|
|
885
|
+
start = 0
|
|
886
|
+
}
|
|
887
|
+
end := failureLine + 11
|
|
888
|
+
if end > len(lines) {
|
|
889
|
+
end = len(lines)
|
|
890
|
+
}
|
|
891
|
+
candidate, _ = joinGitHubLogRanges(lines, [][2]int{{start, end}, {maxInt(0, len(lines)-6), len(lines)}})
|
|
892
|
+
} else {
|
|
893
|
+
strategy = "head+tail"
|
|
894
|
+
candidate, _ = joinGitHubLogRanges(lines, [][2]int{{0, minInt(7, len(lines))}, {maxInt(0, len(lines)-7), len(lines)}})
|
|
895
|
+
}
|
|
896
|
+
preview, _ := githubOverviewPreview(candidate, maxRunes)
|
|
897
|
+
return githubJobLogPreview{
|
|
898
|
+
Text: preview,
|
|
899
|
+
Strategy: strategy,
|
|
900
|
+
Truncated: true,
|
|
901
|
+
LinesTotal: len(lines),
|
|
902
|
+
LinesShown: strings.Count(preview, "\n") + 1,
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
func findGitHubJobFailureLogLine(lines []string, job githubActionsJob) (int, string) {
|
|
907
|
+
failedSteps := []string{}
|
|
908
|
+
for _, step := range job.Steps {
|
|
909
|
+
if githubActionsHardFailure(step.Conclusion) {
|
|
910
|
+
name := strings.ToLower(strings.TrimSpace(step.Name))
|
|
911
|
+
if name != "" {
|
|
912
|
+
failedSteps = append(failedSteps, name)
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if len(failedSteps) > 0 {
|
|
917
|
+
for i, line := range lines {
|
|
918
|
+
lower := strings.ToLower(line)
|
|
919
|
+
for _, step := range failedSteps {
|
|
920
|
+
if strings.Contains(lower, step) {
|
|
921
|
+
return i, "failed-step-context+tail"
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if githubActionsHardFailure(job.Conclusion) {
|
|
927
|
+
markers := []string{"##[error]", "error:", "fatal:", "panic:", "failed", "failure", "exit code"}
|
|
928
|
+
for i, line := range lines {
|
|
929
|
+
lower := strings.ToLower(line)
|
|
930
|
+
for _, marker := range markers {
|
|
931
|
+
if strings.Contains(lower, marker) {
|
|
932
|
+
return i, "failure-marker-context+tail"
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return -1, "head+tail"
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
func githubActionsHardFailure(conclusion string) bool {
|
|
941
|
+
switch strings.ToLower(strings.TrimSpace(conclusion)) {
|
|
942
|
+
case "failure", "error", "cancelled", "canceled", "timed_out", "action_required", "startup_failure":
|
|
943
|
+
return true
|
|
944
|
+
default:
|
|
945
|
+
return false
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
func joinGitHubLogRanges(lines []string, ranges [][2]int) (string, int) {
|
|
950
|
+
type logRange struct{ start, end int }
|
|
951
|
+
clean := []logRange{}
|
|
952
|
+
for _, raw := range ranges {
|
|
953
|
+
start, end := raw[0], raw[1]
|
|
954
|
+
if start < 0 {
|
|
955
|
+
start = 0
|
|
956
|
+
}
|
|
957
|
+
if end > len(lines) {
|
|
958
|
+
end = len(lines)
|
|
959
|
+
}
|
|
960
|
+
if start >= end {
|
|
961
|
+
continue
|
|
962
|
+
}
|
|
963
|
+
if len(clean) > 0 && start <= clean[len(clean)-1].end {
|
|
964
|
+
if end > clean[len(clean)-1].end {
|
|
965
|
+
clean[len(clean)-1].end = end
|
|
966
|
+
}
|
|
967
|
+
continue
|
|
968
|
+
}
|
|
969
|
+
clean = append(clean, logRange{start: start, end: end})
|
|
970
|
+
}
|
|
971
|
+
parts := []string{}
|
|
972
|
+
shown := 0
|
|
973
|
+
previousEnd := 0
|
|
974
|
+
for i, item := range clean {
|
|
975
|
+
if i == 0 && item.start > 0 {
|
|
976
|
+
parts = append(parts, fmt.Sprintf("[... %d earlier log lines omitted ...]", item.start))
|
|
977
|
+
} else if i > 0 && item.start > previousEnd {
|
|
978
|
+
parts = append(parts, fmt.Sprintf("[... %d log lines omitted ...]", item.start-previousEnd))
|
|
979
|
+
}
|
|
980
|
+
parts = append(parts, strings.Join(lines[item.start:item.end], "\n"))
|
|
981
|
+
shown += item.end - item.start
|
|
982
|
+
previousEnd = item.end
|
|
983
|
+
}
|
|
984
|
+
if len(clean) > 0 && previousEnd < len(lines) {
|
|
985
|
+
parts = append(parts, fmt.Sprintf("[... %d later log lines omitted ...]", len(lines)-previousEnd))
|
|
986
|
+
}
|
|
987
|
+
return strings.Join(parts, "\n"), shown
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
func markdownFenceForText(text string) string {
|
|
991
|
+
for length := 3; ; length++ {
|
|
992
|
+
fence := strings.Repeat("`", length)
|
|
993
|
+
if !strings.Contains(text, fence) {
|
|
994
|
+
return fence
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
func githubActionsJobLogEndpointURL(target *GitHubTarget) string {
|
|
1000
|
+
return fmt.Sprintf("https://api.github.com/repos/%s/%s/actions/jobs/%d/logs", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), target.JobID)
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
func maxInt(a, b int) int {
|
|
1004
|
+
if a > b {
|
|
1005
|
+
return a
|
|
1006
|
+
}
|
|
1007
|
+
return b
|
|
1008
|
+
}
|