webctx 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENTS.md +97 -0
  2. package/CONTRIBUTORS.md +126 -0
  3. package/LICENSE +202 -0
  4. package/Makefile +69 -0
  5. package/README.md +121 -0
  6. package/bin/webctx.js +28 -0
  7. package/cmd/webctx/main.go +11 -0
  8. package/docs/porting-status.md +173 -0
  9. package/go.mod +3 -0
  10. package/internal/app/app.go +139 -0
  11. package/internal/app/app_test.go +194 -0
  12. package/internal/app/github.go +2084 -0
  13. package/internal/app/github_acceptance_test.go +166 -0
  14. package/internal/app/github_actions.go +1008 -0
  15. package/internal/app/github_actions_test.go +484 -0
  16. package/internal/app/github_activity_deployments.go +503 -0
  17. package/internal/app/github_activity_deployments_test.go +321 -0
  18. package/internal/app/github_commits.go +1004 -0
  19. package/internal/app/github_commits_test.go +503 -0
  20. package/internal/app/github_discussions_gists.go +1156 -0
  21. package/internal/app/github_discussions_gists_test.go +425 -0
  22. package/internal/app/github_issues.go +1616 -0
  23. package/internal/app/github_issues_test.go +731 -0
  24. package/internal/app/github_packages_projects.go +429 -0
  25. package/internal/app/github_packages_projects_test.go +168 -0
  26. package/internal/app/github_phase9_overview_stress_test.go +178 -0
  27. package/internal/app/github_pull_views.go +1141 -0
  28. package/internal/app/github_pull_views_test.go +603 -0
  29. package/internal/app/github_pulls.go +1459 -0
  30. package/internal/app/github_pulls_test.go +715 -0
  31. package/internal/app/github_refs.go +621 -0
  32. package/internal/app/github_refs_test.go +260 -0
  33. package/internal/app/github_search_profiles.go +505 -0
  34. package/internal/app/github_search_profiles_test.go +352 -0
  35. package/internal/app/github_test.go +888 -0
  36. package/internal/app/scrape.go +264 -0
  37. package/internal/app/tools.go +670 -0
  38. package/internal/buildinfo/buildinfo.go +16 -0
  39. package/package.json +66 -0
  40. package/scripts/postinstall.js +137 -0
@@ -0,0 +1,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
+ }