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