Go · 34419 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package repo
4
5 import (
6 "bytes"
7 "context"
8 "errors"
9 "io"
10 "net/http"
11 "net/url"
12 "path"
13 "strconv"
14 "strings"
15 "time"
16
17 "github.com/go-chi/chi/v5"
18 "github.com/jackc/pgx/v5"
19 "github.com/jackc/pgx/v5/pgtype"
20
21 actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
22 "github.com/tenseleyFlow/shithub/internal/auth/policy"
23 "github.com/tenseleyFlow/shithub/internal/infra/storage"
24 )
25
26 const actionsRunsPageSize = int32(20)
27 const actionsStepLogRenderLimit = 1 << 20
28
29 type actionsWorkflowView struct {
30 File string
31 Name string
32 Count int64
33 Href string
34 Active bool
35 }
36
37 type actionsListRunView struct {
38 ID int64
39 RunIndex int64
40 WorkflowFile string
41 WorkflowName string
42 Title string
43 HeadSha string
44 HeadShaShort string
45 HeadRef string
46 Event string
47 EventLabel string
48 ActorUsername string
49 StateText string
50 StateClass string
51 StateIcon string
52 CreatedAt time.Time
53 UpdatedAt time.Time
54 Duration string
55 Href string
56 }
57
58 type actionsListFilters struct {
59 Workflow string
60 Branch string
61 Event string
62 Status string
63 Conclusion string
64 Actor string
65 Page int32
66 HasAny bool
67 }
68
69 type actionsFilterOption struct {
70 Value string
71 Label string
72 Selected bool
73 }
74
75 type actionsPaginationView struct {
76 Page int32
77 PageSize int32
78 Total int64
79 Start int64
80 End int64
81 HasPrev bool
82 HasNext bool
83 PrevHref string
84 NextHref string
85 ResultText string
86 }
87
88 type actionsRunDetailView struct {
89 ID int64
90 RunIndex int64
91 WorkflowFile string
92 WorkflowName string
93 Title string
94 HeadSha string
95 HeadShaShort string
96 HeadRef string
97 Event string
98 EventLabel string
99 ActorUsername string
100 StateText string
101 StateClass string
102 StateIcon string
103 CreatedAt time.Time
104 UpdatedAt time.Time
105 Duration string
106 IsTerminal bool
107 StatusHref string
108 ActionsHref string
109 CodeHref string
110 ArtifactCount int
111 JobCount int
112 CompletedCount int
113 FailureCount int
114 Jobs []actionsJobDetailView
115 Stages []actionsJobStageView
116 }
117
118 type actionsJobDetailView struct {
119 ID int64
120 JobIndex int32
121 JobKey string
122 Name string
123 RunsOn string
124 Needs []string
125 NeedsText string
126 StateText string
127 StateClass string
128 StateIcon string
129 Duration string
130 Anchor string
131 Depth int
132 Steps []actionsStepDetailView
133 }
134
135 type actionsStepDetailView struct {
136 ID int64
137 StepIndex int32
138 StepID string
139 Name string
140 Kind string
141 Detail string
142 StateText string
143 StateClass string
144 StateIcon string
145 Duration string
146 LogByteCount int64
147 LogHref string
148 }
149
150 type actionsJobStageView struct {
151 Index int
152 Jobs []actionsJobDetailView
153 }
154
155 type actionsStepLogView struct {
156 Run actionsRunDetailView
157 Job actionsJobDetailView
158 Step actionsStepDetailView
159 LogText string
160 LogSource string
161 LogError string
162 LogTruncated bool
163 DownloadURL string
164 BackHref string
165 }
166
167 func (h *Handlers) repoTabActions(w http.ResponseWriter, r *http.Request) {
168 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
169 if !ok {
170 return
171 }
172
173 filters := actionsListFiltersFromRequest(r)
174 q := actionsdb.New()
175 params := workflowRunListParams(row.ID, filters)
176 params.PageLimit = actionsRunsPageSize
177 params.PageOffset = (filters.Page - 1) * actionsRunsPageSize
178
179 runs, err := q.ListWorkflowRunsForRepo(r.Context(), h.d.Pool, params)
180 if err != nil {
181 h.d.Logger.WarnContext(r.Context(), "repo actions: list workflow runs", "repo_id", row.ID, "error", err)
182 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
183 return
184 }
185 filteredCount, err := q.CountWorkflowRunsForRepo(r.Context(), h.d.Pool, workflowRunCountParams(row.ID, filters))
186 if err != nil {
187 h.d.Logger.WarnContext(r.Context(), "repo actions: count workflow runs", "repo_id", row.ID, "error", err)
188 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
189 return
190 }
191 workflowRows, err := q.ListWorkflowRunWorkflowsForRepo(r.Context(), h.d.Pool, row.ID)
192 if err != nil {
193 h.d.Logger.WarnContext(r.Context(), "repo actions: list workflows", "repo_id", row.ID, "error", err)
194 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
195 return
196 }
197
198 basePath := "/" + owner.Username + "/" + row.Name + "/actions"
199 workflows, allRunCount, activeWorkflowName := actionsWorkflowViews(workflowRows, filters, basePath)
200 runViews := make([]actionsListRunView, 0, len(runs))
201 now := time.Now()
202 for _, run := range runs {
203 runViews = append(runViews, actionsListRunViewFromRow(run, owner.Username, row.Name, now))
204 }
205
206 data := h.repoHeaderData(r, row, owner.Username, "actions")
207 data["Title"] = "Actions · " + row.Name
208 data["Runs"] = runViews
209 data["Workflows"] = workflows
210 data["RunCount"] = allRunCount
211 data["FilteredRunCount"] = filteredCount
212 data["ActiveWorkflowName"] = activeWorkflowName
213 data["Filters"] = filters
214 data["EventOptions"] = actionsEventOptions(filters.Event)
215 data["StatusOptions"] = actionsStatusOptions(filters.Status)
216 data["ConclusionOptions"] = actionsConclusionOptions(filters.Conclusion)
217 data["Pagination"] = actionsPagination(basePath, filters, filteredCount, int64(len(runViews)))
218 if err := h.d.Render.RenderPage(w, r, "repo/actions", data); err != nil {
219 h.d.Logger.ErrorContext(r.Context(), "repo actions render", "error", err)
220 }
221 }
222
223 func actionsListFiltersFromRequest(r *http.Request) actionsListFilters {
224 q := r.URL.Query()
225 f := actionsListFilters{
226 Workflow: trimFilter(q.Get("workflow"), 256),
227 Branch: trimFilter(q.Get("branch"), 256),
228 Event: validWorkflowRunEvent(q.Get("event")),
229 Status: validWorkflowRunStatus(q.Get("status")),
230 Conclusion: validWorkflowRunConclusion(q.Get("conclusion")),
231 Actor: trimFilter(q.Get("actor"), 39),
232 Page: parseActionsPage(q.Get("page")),
233 }
234 f.HasAny = f.Workflow != "" || f.Branch != "" || f.Event != "" || f.Status != "" || f.Conclusion != "" || f.Actor != ""
235 return f
236 }
237
238 func trimFilter(v string, max int) string {
239 v = strings.TrimSpace(v)
240 if len(v) > max {
241 return v[:max]
242 }
243 return v
244 }
245
246 func parseActionsPage(v string) int32 {
247 page, err := strconv.ParseInt(strings.TrimSpace(v), 10, 32)
248 if err != nil || page < 1 {
249 return 1
250 }
251 if page > 100000 {
252 return 100000
253 }
254 return int32(page)
255 }
256
257 func workflowRunListParams(repoID int64, filters actionsListFilters) actionsdb.ListWorkflowRunsForRepoParams {
258 return actionsdb.ListWorkflowRunsForRepoParams{
259 RepoID: repoID,
260 WorkflowFile: nullableText(filters.Workflow),
261 HeadRef: nullableText(filters.Branch),
262 Event: nullableWorkflowRunEvent(filters.Event),
263 Status: nullableWorkflowRunStatus(filters.Status),
264 Conclusion: nullableWorkflowRunConclusion(filters.Conclusion),
265 ActorUsername: nullableText(filters.Actor),
266 }
267 }
268
269 func workflowRunCountParams(repoID int64, filters actionsListFilters) actionsdb.CountWorkflowRunsForRepoParams {
270 return actionsdb.CountWorkflowRunsForRepoParams{
271 RepoID: repoID,
272 WorkflowFile: nullableText(filters.Workflow),
273 HeadRef: nullableText(filters.Branch),
274 Event: nullableWorkflowRunEvent(filters.Event),
275 Status: nullableWorkflowRunStatus(filters.Status),
276 Conclusion: nullableWorkflowRunConclusion(filters.Conclusion),
277 ActorUsername: nullableText(filters.Actor),
278 }
279 }
280
281 func nullableText(v string) pgtype.Text {
282 if v == "" {
283 return pgtype.Text{}
284 }
285 return pgtype.Text{String: v, Valid: true}
286 }
287
288 func nullableWorkflowRunEvent(v string) actionsdb.NullWorkflowRunEvent {
289 if v == "" {
290 return actionsdb.NullWorkflowRunEvent{}
291 }
292 return actionsdb.NullWorkflowRunEvent{WorkflowRunEvent: actionsdb.WorkflowRunEvent(v), Valid: true}
293 }
294
295 func nullableWorkflowRunStatus(v string) actionsdb.NullWorkflowRunStatus {
296 if v == "" {
297 return actionsdb.NullWorkflowRunStatus{}
298 }
299 return actionsdb.NullWorkflowRunStatus{WorkflowRunStatus: actionsdb.WorkflowRunStatus(v), Valid: true}
300 }
301
302 func nullableWorkflowRunConclusion(v string) actionsdb.NullCheckConclusion {
303 if v == "" {
304 return actionsdb.NullCheckConclusion{}
305 }
306 return actionsdb.NullCheckConclusion{CheckConclusion: actionsdb.CheckConclusion(v), Valid: true}
307 }
308
309 func actionsWorkflowViews(rows []actionsdb.ListWorkflowRunWorkflowsForRepoRow, filters actionsListFilters, basePath string) ([]actionsWorkflowView, int64, string) {
310 params := actionsFilterParams(filters)
311 params.Del("page")
312 out := make([]actionsWorkflowView, 0, len(rows))
313 var total int64
314 activeName := ""
315 for _, row := range rows {
316 total += row.RunCount
317 name := workflowDisplayName(row.WorkflowName, row.WorkflowFile)
318 p := cloneValues(params)
319 p.Set("workflow", row.WorkflowFile)
320 active := filters.Workflow == row.WorkflowFile
321 if active {
322 activeName = name
323 }
324 out = append(out, actionsWorkflowView{
325 File: row.WorkflowFile,
326 Name: name,
327 Count: row.RunCount,
328 Href: pathWithQuery(basePath, p),
329 Active: active,
330 })
331 }
332 return out, total, activeName
333 }
334
335 func actionsListRunViewFromRow(row actionsdb.ListWorkflowRunsForRepoRow, owner, repoName string, now time.Time) actionsListRunView {
336 stateText, stateClass, stateIcon := workflowRunState(row.Status, row.Conclusion)
337 title := workflowDisplayName(row.WorkflowName, row.WorkflowFile)
338 updatedAt := row.UpdatedAt.Time
339 if updatedAt.IsZero() {
340 updatedAt = row.CreatedAt.Time
341 }
342 return actionsListRunView{
343 ID: row.ID,
344 RunIndex: row.RunIndex,
345 WorkflowFile: row.WorkflowFile,
346 WorkflowName: row.WorkflowName,
347 Title: title,
348 HeadSha: row.HeadSha,
349 HeadShaShort: shortSHA(row.HeadSha),
350 HeadRef: row.HeadRef,
351 Event: string(row.Event),
352 EventLabel: workflowRunEventLabel(string(row.Event)),
353 ActorUsername: row.ActorUsername,
354 StateText: stateText,
355 StateClass: stateClass,
356 StateIcon: stateIcon,
357 CreatedAt: row.CreatedAt.Time,
358 UpdatedAt: updatedAt,
359 Duration: workflowRunDuration(row.Status, row.StartedAt, row.CompletedAt, row.CreatedAt, updatedAt, now),
360 Href: "/" + owner + "/" + repoName + "/actions/runs/" + strconv.FormatInt(row.RunIndex, 10),
361 }
362 }
363
364 func workflowDisplayName(name, file string) string {
365 name = strings.TrimSpace(name)
366 if name != "" {
367 return name
368 }
369 base := path.Base(file)
370 ext := path.Ext(base)
371 if ext != "" {
372 base = strings.TrimSuffix(base, ext)
373 }
374 if base == "." || base == "/" || base == "" {
375 return file
376 }
377 return base
378 }
379
380 func workflowRunState(status actionsdb.WorkflowRunStatus, conclusion actionsdb.NullCheckConclusion) (string, string, string) {
381 switch status {
382 case actionsdb.WorkflowRunStatusQueued:
383 return "Queued", "pending", "dot-fill"
384 case actionsdb.WorkflowRunStatusRunning:
385 return "In progress", "running", "dot-fill"
386 case actionsdb.WorkflowRunStatusCancelled:
387 return "Cancelled", "neutral", "x-circle"
388 case actionsdb.WorkflowRunStatusCompleted:
389 if !conclusion.Valid {
390 return "Completed", "neutral", "check-circle"
391 }
392 default:
393 if !conclusion.Valid {
394 return string(status), "neutral", "dot-fill"
395 }
396 }
397 switch conclusion.CheckConclusion {
398 case actionsdb.CheckConclusionSuccess, actionsdb.CheckConclusionSkipped, actionsdb.CheckConclusionNeutral:
399 return "Success", "success", "check-circle-fill"
400 case actionsdb.CheckConclusionFailure, actionsdb.CheckConclusionTimedOut, actionsdb.CheckConclusionActionRequired:
401 return "Failure", "failure", "x-circle-fill"
402 case actionsdb.CheckConclusionCancelled, actionsdb.CheckConclusionStale:
403 return "Cancelled", "neutral", "x-circle"
404 default:
405 return string(conclusion.CheckConclusion), "neutral", "dot-fill"
406 }
407 }
408
409 func workflowRunDuration(status actionsdb.WorkflowRunStatus, startedAt, completedAt, createdAt pgtype.Timestamptz, updatedAt, now time.Time) string {
410 if status == actionsdb.WorkflowRunStatusQueued {
411 return "—"
412 }
413 start := createdAt.Time
414 if startedAt.Valid {
415 start = startedAt.Time
416 }
417 end := updatedAt
418 if status == actionsdb.WorkflowRunStatusRunning {
419 end = now
420 } else if completedAt.Valid {
421 end = completedAt.Time
422 }
423 return formatDuration(end.Sub(start))
424 }
425
426 func validWorkflowRunEvent(v string) string {
427 switch strings.TrimSpace(v) {
428 case "push", "pull_request", "schedule", "workflow_dispatch":
429 return strings.TrimSpace(v)
430 default:
431 return ""
432 }
433 }
434
435 func validWorkflowRunStatus(v string) string {
436 switch strings.TrimSpace(v) {
437 case "queued", "running", "completed", "cancelled":
438 return strings.TrimSpace(v)
439 default:
440 return ""
441 }
442 }
443
444 func validWorkflowRunConclusion(v string) string {
445 switch strings.TrimSpace(v) {
446 case "success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", "stale":
447 return strings.TrimSpace(v)
448 default:
449 return ""
450 }
451 }
452
453 func actionsEventOptions(selected string) []actionsFilterOption {
454 return selectedOptions(selected, []actionsFilterOption{
455 {Value: "", Label: "Any event"},
456 {Value: "push", Label: "push"},
457 {Value: "pull_request", Label: "pull_request"},
458 {Value: "schedule", Label: "schedule"},
459 {Value: "workflow_dispatch", Label: "workflow_dispatch"},
460 })
461 }
462
463 func actionsStatusOptions(selected string) []actionsFilterOption {
464 return selectedOptions(selected, []actionsFilterOption{
465 {Value: "", Label: "Any status"},
466 {Value: "queued", Label: "queued"},
467 {Value: "running", Label: "running"},
468 {Value: "completed", Label: "completed"},
469 {Value: "cancelled", Label: "cancelled"},
470 })
471 }
472
473 func actionsConclusionOptions(selected string) []actionsFilterOption {
474 return selectedOptions(selected, []actionsFilterOption{
475 {Value: "", Label: "Any conclusion"},
476 {Value: "success", Label: "success"},
477 {Value: "failure", Label: "failure"},
478 {Value: "neutral", Label: "neutral"},
479 {Value: "cancelled", Label: "cancelled"},
480 {Value: "skipped", Label: "skipped"},
481 {Value: "timed_out", Label: "timed_out"},
482 {Value: "action_required", Label: "action_required"},
483 {Value: "stale", Label: "stale"},
484 })
485 }
486
487 func selectedOptions(selected string, opts []actionsFilterOption) []actionsFilterOption {
488 out := make([]actionsFilterOption, len(opts))
489 copy(out, opts)
490 for i := range out {
491 out[i].Selected = out[i].Value == selected
492 }
493 return out
494 }
495
496 func workflowRunEventLabel(v string) string {
497 switch v {
498 case "pull_request":
499 return "pull request"
500 case "workflow_dispatch":
501 return "workflow dispatch"
502 default:
503 return v
504 }
505 }
506
507 func actionsPagination(basePath string, filters actionsListFilters, total, pageRows int64) actionsPaginationView {
508 offset := int64((filters.Page - 1) * actionsRunsPageSize)
509 view := actionsPaginationView{
510 Page: filters.Page,
511 PageSize: actionsRunsPageSize,
512 Total: total,
513 HasPrev: filters.Page > 1,
514 HasNext: offset+pageRows < total,
515 }
516 if total == 0 {
517 view.ResultText = "No workflow runs"
518 return view
519 }
520 view.Start = offset + 1
521 view.End = offset + pageRows
522 view.ResultText = strconv.FormatInt(view.Start, 10) + "-" + strconv.FormatInt(view.End, 10) + " of " + strconv.FormatInt(total, 10)
523 if view.HasPrev {
524 p := actionsFilterParams(filters)
525 if filters.Page <= 2 {
526 p.Del("page")
527 } else {
528 p.Set("page", strconv.FormatInt(int64(filters.Page-1), 10))
529 }
530 view.PrevHref = pathWithQuery(basePath, p)
531 }
532 if view.HasNext {
533 p := actionsFilterParams(filters)
534 p.Set("page", strconv.FormatInt(int64(filters.Page+1), 10))
535 view.NextHref = pathWithQuery(basePath, p)
536 }
537 return view
538 }
539
540 func actionsFilterParams(filters actionsListFilters) url.Values {
541 v := url.Values{}
542 if filters.Workflow != "" {
543 v.Set("workflow", filters.Workflow)
544 }
545 if filters.Branch != "" {
546 v.Set("branch", filters.Branch)
547 }
548 if filters.Event != "" {
549 v.Set("event", filters.Event)
550 }
551 if filters.Status != "" {
552 v.Set("status", filters.Status)
553 }
554 if filters.Conclusion != "" {
555 v.Set("conclusion", filters.Conclusion)
556 }
557 if filters.Actor != "" {
558 v.Set("actor", filters.Actor)
559 }
560 if filters.Page > 1 {
561 v.Set("page", strconv.FormatInt(int64(filters.Page), 10))
562 }
563 return v
564 }
565
566 func cloneValues(v url.Values) url.Values {
567 out := url.Values{}
568 for key, values := range v {
569 for _, value := range values {
570 out.Add(key, value)
571 }
572 }
573 return out
574 }
575
576 func pathWithQuery(basePath string, q url.Values) string {
577 if encoded := q.Encode(); encoded != "" {
578 return basePath + "?" + encoded
579 }
580 return basePath
581 }
582
583 func (h *Handlers) repoActionRun(w http.ResponseWriter, r *http.Request) {
584 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
585 if !ok {
586 return
587 }
588 runIndex, ok := parsePositiveInt64Param(r, "runIndex")
589 if !ok {
590 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
591 return
592 }
593 view, err := h.loadActionsRunDetail(r.Context(), row.ID, owner.Username, row.Name, runIndex)
594 if err != nil {
595 if errors.Is(err, pgx.ErrNoRows) {
596 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
597 } else {
598 h.d.Logger.WarnContext(r.Context(), "repo actions: get run detail", "repo_id", row.ID, "run_index", runIndex, "error", err)
599 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
600 }
601 return
602 }
603
604 data := h.repoHeaderData(r, row, owner.Username, "actions")
605 data["Title"] = view.Title + " #" + strconv.FormatInt(view.RunIndex, 10) + " · " + row.Name
606 data["Run"] = view
607 data["UseHTMX"] = true
608 if err := h.d.Render.RenderPage(w, r, "repo/action_run", data); err != nil {
609 h.d.Logger.ErrorContext(r.Context(), "repo action run render", "run_index", runIndex, "error", err)
610 }
611 }
612
613 func (h *Handlers) repoActionRunStatus(w http.ResponseWriter, r *http.Request) {
614 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
615 if !ok {
616 return
617 }
618 runIndex, ok := parsePositiveInt64Param(r, "runIndex")
619 if !ok {
620 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
621 return
622 }
623 view, err := h.loadActionsRunDetail(r.Context(), row.ID, owner.Username, row.Name, runIndex)
624 if err != nil {
625 if errors.Is(err, pgx.ErrNoRows) {
626 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
627 } else {
628 h.d.Logger.WarnContext(r.Context(), "repo actions: get run status", "repo_id", row.ID, "run_index", runIndex, "error", err)
629 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
630 }
631 return
632 }
633
634 data := h.repoHeaderData(r, row, owner.Username, "actions")
635 data["Run"] = view
636 if err := h.d.Render.RenderFragment(w, "repo/action_run_status", data); err != nil {
637 h.d.Logger.ErrorContext(r.Context(), "repo action run status render", "run_index", runIndex, "error", err)
638 }
639 }
640
641 func (h *Handlers) repoActionStepLog(w http.ResponseWriter, r *http.Request) {
642 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
643 if !ok {
644 return
645 }
646 runIndex, ok := parsePositiveInt64Param(r, "runIndex")
647 if !ok {
648 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
649 return
650 }
651 jobIndex, ok := parseNonNegativeInt32Param(r, "jobIndex")
652 if !ok {
653 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
654 return
655 }
656 stepIndex, ok := parseNonNegativeInt32Param(r, "stepIndex")
657 if !ok {
658 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
659 return
660 }
661 run, err := h.loadActionsRunDetail(r.Context(), row.ID, owner.Username, row.Name, runIndex)
662 if err != nil {
663 if errors.Is(err, pgx.ErrNoRows) {
664 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
665 } else {
666 h.d.Logger.WarnContext(r.Context(), "repo actions: get run for step log", "repo_id", row.ID, "run_index", runIndex, "error", err)
667 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
668 }
669 return
670 }
671
672 job, step, ok := findActionStep(run, jobIndex, stepIndex)
673 if !ok {
674 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
675 return
676 }
677 logContent, err := h.loadStepLogContent(r.Context(), step.ID)
678 if err != nil {
679 h.d.Logger.WarnContext(r.Context(), "repo actions: load step log", "step_id", step.ID, "error", err)
680 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
681 return
682 }
683
684 view := actionsStepLogView{
685 Run: run,
686 Job: job,
687 Step: step,
688 LogText: logContent.Text,
689 LogSource: logContent.Source,
690 LogError: logContent.Error,
691 LogTruncated: logContent.Truncated,
692 DownloadURL: logContent.DownloadURL,
693 BackHref: run.ActionsHref + "/runs/" + strconv.FormatInt(run.RunIndex, 10) + "#job-" + strconv.FormatInt(int64(job.JobIndex), 10),
694 }
695 data := h.repoHeaderData(r, row, owner.Username, "actions")
696 data["Title"] = step.Name + " · " + run.Title + " #" + strconv.FormatInt(run.RunIndex, 10)
697 data["Log"] = view
698 if err := h.d.Render.RenderPage(w, r, "repo/action_step_log", data); err != nil {
699 h.d.Logger.ErrorContext(r.Context(), "repo action step log render", "run_index", runIndex, "job_index", jobIndex, "step_index", stepIndex, "error", err)
700 }
701 }
702
703 func (h *Handlers) loadActionsRunDetail(ctx context.Context, repoID int64, owner, repoName string, runIndex int64) (actionsRunDetailView, error) {
704 q := actionsdb.New()
705 run, err := q.GetWorkflowRunForRepoByIndex(ctx, h.d.Pool, actionsdb.GetWorkflowRunForRepoByIndexParams{
706 RepoID: repoID,
707 RunIndex: runIndex,
708 })
709 if err != nil {
710 return actionsRunDetailView{}, err
711 }
712 jobs, err := q.ListJobsForRun(ctx, h.d.Pool, run.ID)
713 if err != nil {
714 return actionsRunDetailView{}, err
715 }
716 artifacts, err := q.ListArtifactsForRun(ctx, h.d.Pool, run.ID)
717 if err != nil {
718 return actionsRunDetailView{}, err
719 }
720
721 basePath := "/" + owner + "/" + repoName + "/actions"
722 runPath := basePath + "/runs/" + strconv.FormatInt(run.RunIndex, 10)
723 now := time.Now()
724 stateText, stateClass, stateIcon := workflowRunState(run.Status, run.Conclusion)
725 updatedAt := pgTime(run.UpdatedAt, run.CreatedAt.Time)
726 view := actionsRunDetailView{
727 ID: run.ID,
728 RunIndex: run.RunIndex,
729 WorkflowFile: run.WorkflowFile,
730 WorkflowName: run.WorkflowName,
731 Title: workflowDisplayName(run.WorkflowName, run.WorkflowFile),
732 HeadSha: run.HeadSha,
733 HeadShaShort: shortSHA(run.HeadSha),
734 HeadRef: run.HeadRef,
735 Event: string(run.Event),
736 EventLabel: workflowRunEventLabel(string(run.Event)),
737 ActorUsername: run.ActorUsername,
738 StateText: stateText,
739 StateClass: stateClass,
740 StateIcon: stateIcon,
741 CreatedAt: run.CreatedAt.Time,
742 UpdatedAt: updatedAt,
743 Duration: workflowRunDuration(run.Status, run.StartedAt, run.CompletedAt, run.CreatedAt, updatedAt, now),
744 IsTerminal: workflowRunTerminal(run.Status),
745 StatusHref: runPath + "/status",
746 ActionsHref: basePath,
747 CodeHref: "/" + owner + "/" + repoName + "/tree/" + codeTarget(run.HeadRef, run.HeadSha),
748 ArtifactCount: len(artifacts),
749 JobCount: len(jobs),
750 CompletedCount: 0,
751 FailureCount: 0,
752 Jobs: make([]actionsJobDetailView, 0, len(jobs)),
753 }
754 for _, job := range jobs {
755 steps, err := q.ListStepsForJob(ctx, h.d.Pool, job.ID)
756 if err != nil {
757 return actionsRunDetailView{}, err
758 }
759 jobView := actionsJobDetailViewFromRow(job, owner, repoName, run.RunIndex, now)
760 jobView.Steps = make([]actionsStepDetailView, 0, len(steps))
761 for _, step := range steps {
762 jobView.Steps = append(jobView.Steps, actionsStepDetailViewFromRow(step, owner, repoName, run.RunIndex, job.JobIndex, now))
763 }
764 if job.Status == actionsdb.WorkflowJobStatusCompleted || job.Status == actionsdb.WorkflowJobStatusCancelled || job.Status == actionsdb.WorkflowJobStatusSkipped {
765 view.CompletedCount++
766 }
767 if jobView.StateClass == "failure" {
768 view.FailureCount++
769 }
770 view.Jobs = append(view.Jobs, jobView)
771 }
772 view.Stages = actionsJobStages(view.Jobs)
773 return view, nil
774 }
775
776 func actionsJobDetailViewFromRow(row actionsdb.ListJobsForRunRow, owner, repoName string, runIndex int64, now time.Time) actionsJobDetailView {
777 stateText, stateClass, stateIcon := workflowJobState(row.Status, row.Conclusion)
778 name := strings.TrimSpace(row.JobName)
779 if name == "" {
780 name = row.JobKey
781 }
782 return actionsJobDetailView{
783 ID: row.ID,
784 JobIndex: row.JobIndex,
785 JobKey: row.JobKey,
786 Name: name,
787 RunsOn: row.RunsOn,
788 Needs: append([]string(nil), row.NeedsJobs...),
789 NeedsText: strings.Join(row.NeedsJobs, ", "),
790 StateText: stateText,
791 StateClass: stateClass,
792 StateIcon: stateIcon,
793 Duration: actionItemDuration(string(row.Status), string(actionsdb.WorkflowJobStatusQueued), row.StartedAt, row.CompletedAt, row.CreatedAt, row.UpdatedAt, now),
794 Anchor: "job-" + strconv.FormatInt(int64(row.JobIndex), 10),
795 }
796 }
797
798 func actionsStepDetailViewFromRow(row actionsdb.ListStepsForJobRow, owner, repoName string, runIndex int64, jobIndex int32, now time.Time) actionsStepDetailView {
799 stateText, stateClass, stateIcon := workflowStepState(row.Status, row.Conclusion)
800 name, kind, detail := workflowStepDisplay(row)
801 return actionsStepDetailView{
802 ID: row.ID,
803 StepIndex: row.StepIndex,
804 StepID: row.StepID,
805 Name: name,
806 Kind: kind,
807 Detail: detail,
808 StateText: stateText,
809 StateClass: stateClass,
810 StateIcon: stateIcon,
811 Duration: actionItemDuration(string(row.Status), string(actionsdb.WorkflowStepStatusQueued), row.StartedAt, row.CompletedAt, row.CreatedAt, row.UpdatedAt, now),
812 LogByteCount: row.LogByteCount,
813 LogHref: "/" + owner + "/" + repoName + "/actions/runs/" + strconv.FormatInt(runIndex, 10) +
814 "/jobs/" + strconv.FormatInt(int64(jobIndex), 10) +
815 "/steps/" + strconv.FormatInt(int64(row.StepIndex), 10),
816 }
817 }
818
819 func workflowStepDisplay(row actionsdb.ListStepsForJobRow) (name, kind, detail string) {
820 if row.UsesAlias != "" {
821 kind = "uses"
822 detail = row.UsesAlias
823 } else {
824 kind = "run"
825 detail = firstCommandLine(row.RunCommand)
826 }
827 name = strings.TrimSpace(row.StepName)
828 if name == "" {
829 name = strings.TrimSpace(detail)
830 }
831 if name == "" {
832 name = "Step " + strconv.Itoa(int(row.StepIndex)+1)
833 }
834 if len(detail) > 120 {
835 detail = detail[:117] + "..."
836 }
837 return name, kind, detail
838 }
839
840 func firstCommandLine(command string) string {
841 for _, line := range strings.Split(command, "\n") {
842 line = strings.TrimSpace(line)
843 if line != "" {
844 return line
845 }
846 }
847 return ""
848 }
849
850 func actionsJobStages(jobs []actionsJobDetailView) []actionsJobStageView {
851 indexByKey := make(map[string]int, len(jobs))
852 for i := range jobs {
853 indexByKey[jobs[i].JobKey] = i
854 }
855 state := make(map[string]int, len(jobs))
856 var depthFor func(string) int
857 depthFor = func(key string) int {
858 i, ok := indexByKey[key]
859 if !ok {
860 return 0
861 }
862 switch state[key] {
863 case 1:
864 return 0
865 case 2:
866 return jobs[i].Depth
867 }
868 state[key] = 1
869 depth := 0
870 for _, need := range jobs[i].Needs {
871 if _, ok := indexByKey[need]; ok {
872 if depDepth := depthFor(need) + 1; depDepth > depth {
873 depth = depDepth
874 }
875 }
876 }
877 jobs[i].Depth = depth
878 state[key] = 2
879 return depth
880 }
881 maxDepth := 0
882 for i := range jobs {
883 depth := depthFor(jobs[i].JobKey)
884 if depth > maxDepth {
885 maxDepth = depth
886 }
887 }
888 stages := make([]actionsJobStageView, maxDepth+1)
889 for i := range stages {
890 stages[i].Index = i
891 }
892 for _, job := range jobs {
893 stages[job.Depth].Jobs = append(stages[job.Depth].Jobs, job)
894 }
895 return stages
896 }
897
898 func workflowJobState(status actionsdb.WorkflowJobStatus, conclusion actionsdb.NullCheckConclusion) (string, string, string) {
899 if status == actionsdb.WorkflowJobStatusSkipped && !conclusion.Valid {
900 return "Skipped", "neutral", "dash"
901 }
902 if status == actionsdb.WorkflowJobStatusCompleted && conclusion.Valid {
903 return workflowConclusionState(conclusion.CheckConclusion)
904 }
905 switch status {
906 case actionsdb.WorkflowJobStatusQueued:
907 return "Queued", "pending", "dot-fill"
908 case actionsdb.WorkflowJobStatusRunning:
909 return "In progress", "running", "dot-fill"
910 case actionsdb.WorkflowJobStatusCancelled:
911 return "Cancelled", "neutral", "x-circle"
912 case actionsdb.WorkflowJobStatusCompleted:
913 return "Completed", "neutral", "check-circle"
914 default:
915 if conclusion.Valid {
916 return workflowConclusionState(conclusion.CheckConclusion)
917 }
918 return string(status), "neutral", "dot-fill"
919 }
920 }
921
922 func workflowStepState(status actionsdb.WorkflowStepStatus, conclusion actionsdb.NullCheckConclusion) (string, string, string) {
923 if status == actionsdb.WorkflowStepStatusSkipped && !conclusion.Valid {
924 return "Skipped", "neutral", "dash"
925 }
926 if status == actionsdb.WorkflowStepStatusCompleted && conclusion.Valid {
927 return workflowConclusionState(conclusion.CheckConclusion)
928 }
929 switch status {
930 case actionsdb.WorkflowStepStatusQueued:
931 return "Queued", "pending", "dot-fill"
932 case actionsdb.WorkflowStepStatusRunning:
933 return "In progress", "running", "dot-fill"
934 case actionsdb.WorkflowStepStatusCancelled:
935 return "Cancelled", "neutral", "x-circle"
936 case actionsdb.WorkflowStepStatusCompleted:
937 return "Completed", "neutral", "check-circle"
938 default:
939 if conclusion.Valid {
940 return workflowConclusionState(conclusion.CheckConclusion)
941 }
942 return string(status), "neutral", "dot-fill"
943 }
944 }
945
946 func workflowConclusionState(conclusion actionsdb.CheckConclusion) (string, string, string) {
947 switch conclusion {
948 case actionsdb.CheckConclusionSuccess, actionsdb.CheckConclusionSkipped, actionsdb.CheckConclusionNeutral:
949 return "Success", "success", "check-circle-fill"
950 case actionsdb.CheckConclusionFailure, actionsdb.CheckConclusionTimedOut, actionsdb.CheckConclusionActionRequired:
951 return "Failure", "failure", "x-circle-fill"
952 case actionsdb.CheckConclusionCancelled, actionsdb.CheckConclusionStale:
953 return "Cancelled", "neutral", "x-circle"
954 default:
955 return string(conclusion), "neutral", "dot-fill"
956 }
957 }
958
959 func workflowRunTerminal(status actionsdb.WorkflowRunStatus) bool {
960 return status == actionsdb.WorkflowRunStatusCompleted || status == actionsdb.WorkflowRunStatusCancelled
961 }
962
963 func actionItemDuration(status string, queuedStatus string, startedAt, completedAt, createdAt, updatedAt pgtype.Timestamptz, now time.Time) string {
964 if status == queuedStatus {
965 return "—"
966 }
967 start := createdAt.Time
968 if startedAt.Valid {
969 start = startedAt.Time
970 }
971 end := pgTime(updatedAt, now)
972 if status == "running" {
973 end = now
974 } else if completedAt.Valid {
975 end = completedAt.Time
976 }
977 return formatDuration(end.Sub(start))
978 }
979
980 func pgTime(ts pgtype.Timestamptz, fallback time.Time) time.Time {
981 if ts.Valid && !ts.Time.IsZero() {
982 return ts.Time
983 }
984 return fallback
985 }
986
987 func codeTarget(ref, sha string) string {
988 if ref != "" {
989 return ref
990 }
991 return sha
992 }
993
994 func findActionStep(run actionsRunDetailView, jobIndex, stepIndex int32) (actionsJobDetailView, actionsStepDetailView, bool) {
995 for _, job := range run.Jobs {
996 if job.JobIndex != jobIndex {
997 continue
998 }
999 for _, step := range job.Steps {
1000 if step.StepIndex == stepIndex {
1001 return job, step, true
1002 }
1003 }
1004 return actionsJobDetailView{}, actionsStepDetailView{}, false
1005 }
1006 return actionsJobDetailView{}, actionsStepDetailView{}, false
1007 }
1008
1009 type actionsStepLogContent struct {
1010 Text string
1011 Source string
1012 Error string
1013 Truncated bool
1014 DownloadURL string
1015 }
1016
1017 func (h *Handlers) loadStepLogContent(ctx context.Context, stepID int64) (actionsStepLogContent, error) {
1018 q := actionsdb.New()
1019 step, err := q.GetWorkflowStepByID(ctx, h.d.Pool, stepID)
1020 if err != nil {
1021 return actionsStepLogContent{}, err
1022 }
1023 if step.LogObjectKey.Valid && step.LogObjectKey.String != "" {
1024 return h.loadArchivedStepLog(ctx, step.LogObjectKey.String)
1025 }
1026 chunks, err := q.ListAllStepLogChunksForStep(ctx, h.d.Pool, step.ID)
1027 if err != nil {
1028 return actionsStepLogContent{}, err
1029 }
1030 buf := bytes.NewBuffer(make([]byte, 0, minInt(actionsStepLogRenderLimit, int(step.LogByteCount)+1)))
1031 truncated := false
1032 for _, chunk := range chunks {
1033 if buf.Len() >= actionsStepLogRenderLimit {
1034 truncated = true
1035 break
1036 }
1037 remaining := actionsStepLogRenderLimit - buf.Len()
1038 if len(chunk.Chunk) > remaining {
1039 _, _ = buf.Write(chunk.Chunk[:remaining])
1040 truncated = true
1041 break
1042 }
1043 _, _ = buf.Write(chunk.Chunk)
1044 }
1045 return actionsStepLogContent{
1046 Text: strings.ToValidUTF8(buf.String(), "\uFFFD"),
1047 Source: "SQL chunks",
1048 Truncated: truncated,
1049 }, nil
1050 }
1051
1052 func (h *Handlers) loadArchivedStepLog(ctx context.Context, key string) (actionsStepLogContent, error) {
1053 if h.d.ObjectStore == nil {
1054 return actionsStepLogContent{
1055 Source: "object storage",
1056 Error: "Archived log storage is not configured for this server.",
1057 }, nil
1058 }
1059 rc, _, err := h.d.ObjectStore.Get(ctx, key)
1060 if err != nil {
1061 if errors.Is(err, storage.ErrNotFound) {
1062 return actionsStepLogContent{
1063 Source: "object storage",
1064 Error: "Archived log object was not found.",
1065 }, nil
1066 }
1067 return actionsStepLogContent{}, err
1068 }
1069 defer rc.Close()
1070 body, truncated, err := readLimitedLog(rc, actionsStepLogRenderLimit)
1071 if err != nil {
1072 return actionsStepLogContent{}, err
1073 }
1074 downloadURL, _ := h.d.ObjectStore.SignedURL(ctx, key, 15*time.Minute, http.MethodGet)
1075 return actionsStepLogContent{
1076 Text: strings.ToValidUTF8(string(body), "\uFFFD"),
1077 Source: "object storage",
1078 Truncated: truncated,
1079 DownloadURL: downloadURL,
1080 }, nil
1081 }
1082
1083 func readLimitedLog(r io.Reader, limit int) ([]byte, bool, error) {
1084 body, err := io.ReadAll(io.LimitReader(r, int64(limit)+1))
1085 if err != nil {
1086 return nil, false, err
1087 }
1088 if len(body) > limit {
1089 return body[:limit], true, nil
1090 }
1091 return body, false, nil
1092 }
1093
1094 func parsePositiveInt64Param(r *http.Request, name string) (int64, bool) {
1095 v, err := strconv.ParseInt(chi.URLParam(r, name), 10, 64)
1096 return v, err == nil && v > 0
1097 }
1098
1099 func parseNonNegativeInt32Param(r *http.Request, name string) (int32, bool) {
1100 v, err := strconv.ParseInt(chi.URLParam(r, name), 10, 32)
1101 return int32(v), err == nil && v >= 0
1102 }
1103
1104 func minInt(a, b int) int {
1105 if a < b {
1106 return a
1107 }
1108 return b
1109 }
1110
1111 func formatDuration(d time.Duration) string {
1112 if d <= 0 {
1113 return "—"
1114 }
1115 if d < time.Minute {
1116 return strconv.Itoa(int(d.Seconds())) + "s"
1117 }
1118 if d < time.Hour {
1119 mins := int(d / time.Minute)
1120 secs := int((d % time.Minute) / time.Second)
1121 if secs == 0 {
1122 return strconv.Itoa(mins) + "m"
1123 }
1124 return strconv.Itoa(mins) + "m " + strconv.Itoa(secs) + "s"
1125 }
1126 hours := int(d / time.Hour)
1127 mins := int((d % time.Hour) / time.Minute)
1128 return strconv.Itoa(hours) + "h " + strconv.Itoa(mins) + "m"
1129 }
1130
1131 func shortSHA(sha string) string {
1132 if len(sha) <= 7 {
1133 return sha
1134 }
1135 return sha[:7]
1136 }
1137