tenseleyflow/shithub / a5f9b2e

Browse files

Render actions runs pages

Authored by espadonne
SHA
a5f9b2e25298a158c52d1b1d6a032781dee11364
Parents
069a4d0
Tree
108cb3d

7 changed files

StatusFile+-
A internal/web/handlers/repo/actions.go 329 0
M internal/web/handlers/repo/deferred_tabs.go 0 13
M internal/web/handlers/repo/repo.go 1 0
M internal/web/render/octicons.go 18 0
M internal/web/static/css/shithub.css 372 0
A internal/web/templates/repo/action_run.html 118 0
A internal/web/templates/repo/actions.html 83 0
internal/web/handlers/repo/actions.goadded
@@ -0,0 +1,329 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"errors"
7
+	"html/template"
8
+	"net/http"
9
+	"strconv"
10
+	"time"
11
+
12
+	"github.com/go-chi/chi/v5"
13
+	"github.com/jackc/pgx/v5"
14
+
15
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
16
+	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
17
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
18
+)
19
+
20
+type actionsWorkflowView struct {
21
+	Name  string
22
+	Count int
23
+}
24
+
25
+type actionsSuiteView struct {
26
+	ID                 int64
27
+	AppSlug            string
28
+	Title              string
29
+	HeadSha            string
30
+	HeadShaShort       string
31
+	PullNumber         int64
32
+	PullAuthorUsername string
33
+	HeadRef            string
34
+	BaseRef            string
35
+	RunCount           int
36
+	StateText          string
37
+	StateClass         string
38
+	StateIcon          string
39
+	CreatedAt          time.Time
40
+	UpdatedAt          time.Time
41
+	Duration           string
42
+	Runs               []actionsRunView
43
+	AnnotationCount    int
44
+}
45
+
46
+type actionsRunView struct {
47
+	ID          int64
48
+	Name        string
49
+	StateText   string
50
+	StateClass  string
51
+	StateIcon   string
52
+	Duration    string
53
+	CompletedAt time.Time
54
+	DetailsURL  string
55
+	SummaryHTML template.HTML
56
+}
57
+
58
+func (h *Handlers) repoTabActions(w http.ResponseWriter, r *http.Request) {
59
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
60
+	if !ok {
61
+		return
62
+	}
63
+	suiteRows, err := h.cq.ListCheckSuitesForRepo(r.Context(), h.d.Pool, checksdb.ListCheckSuitesForRepoParams{
64
+		RepoID: row.ID,
65
+		Limit:  50,
66
+		Offset: 0,
67
+	})
68
+	if err != nil {
69
+		h.d.Logger.WarnContext(r.Context(), "repo actions: list suites", "error", err)
70
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
71
+		return
72
+	}
73
+
74
+	suites := make([]actionsSuiteView, 0, len(suiteRows))
75
+	workflowCounts := map[string]int{}
76
+	workflowOrder := []string{}
77
+	for _, suite := range suiteRows {
78
+		runs, err := h.cq.ListCheckRunsBySuite(r.Context(), h.d.Pool, suite.ID)
79
+		if err != nil {
80
+			h.d.Logger.WarnContext(r.Context(), "repo actions: list runs", "suite_id", suite.ID, "error", err)
81
+			continue
82
+		}
83
+		if _, ok := workflowCounts[suite.AppSlug]; !ok {
84
+			workflowOrder = append(workflowOrder, suite.AppSlug)
85
+		}
86
+		workflowCounts[suite.AppSlug]++
87
+		suites = append(suites, actionsSuiteViewFromListRow(suite, runs))
88
+	}
89
+
90
+	workflows := make([]actionsWorkflowView, 0, len(workflowOrder))
91
+	for _, name := range workflowOrder {
92
+		workflows = append(workflows, actionsWorkflowView{Name: name, Count: workflowCounts[name]})
93
+	}
94
+
95
+	data := h.repoHeaderData(r, row, owner.Username, "actions")
96
+	data["Title"] = "Actions · " + row.Name
97
+	data["Suites"] = suites
98
+	data["Workflows"] = workflows
99
+	data["RunCount"] = len(suites)
100
+	if err := h.d.Render.RenderPage(w, r, "repo/actions", data); err != nil {
101
+		h.d.Logger.ErrorContext(r.Context(), "repo actions render", "error", err)
102
+	}
103
+}
104
+
105
+func (h *Handlers) repoActionRun(w http.ResponseWriter, r *http.Request) {
106
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
107
+	if !ok {
108
+		return
109
+	}
110
+	suiteID, err := strconv.ParseInt(chi.URLParam(r, "suiteID"), 10, 64)
111
+	if err != nil {
112
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
113
+		return
114
+	}
115
+	suite, err := h.cq.GetCheckSuiteForRepo(r.Context(), h.d.Pool, checksdb.GetCheckSuiteForRepoParams{
116
+		RepoID: row.ID,
117
+		ID:     suiteID,
118
+	})
119
+	if err != nil {
120
+		if errors.Is(err, pgx.ErrNoRows) {
121
+			h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
122
+		} else {
123
+			h.d.Logger.WarnContext(r.Context(), "repo actions: get suite", "suite_id", suiteID, "error", err)
124
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
125
+		}
126
+		return
127
+	}
128
+	runs, err := h.cq.ListCheckRunsBySuite(r.Context(), h.d.Pool, suite.ID)
129
+	if err != nil {
130
+		h.d.Logger.WarnContext(r.Context(), "repo actions: get suite runs", "suite_id", suiteID, "error", err)
131
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
132
+		return
133
+	}
134
+
135
+	view := actionsSuiteViewFromGetRow(suite, runs)
136
+	data := h.repoHeaderData(r, row, owner.Username, "actions")
137
+	data["Title"] = view.Title + " · " + row.Name
138
+	data["Run"] = view
139
+	data["CSRFToken"] = middleware.CSRFTokenForRequest(r)
140
+	if err := h.d.Render.RenderPage(w, r, "repo/action_run", data); err != nil {
141
+		h.d.Logger.ErrorContext(r.Context(), "repo action run render", "suite_id", suiteID, "error", err)
142
+	}
143
+}
144
+
145
+func actionsSuiteViewFromListRow(row checksdb.ListCheckSuitesForRepoRow, runs []checksdb.CheckRun) actionsSuiteView {
146
+	return actionsSuiteViewFromParts(
147
+		row.ID,
148
+		row.HeadSha,
149
+		row.AppSlug,
150
+		row.Status,
151
+		row.Conclusion,
152
+		row.CreatedAt.Time,
153
+		row.UpdatedAt.Time,
154
+		row.PullNumber,
155
+		row.PullTitle,
156
+		row.PullAuthorUsername,
157
+		row.HeadRef,
158
+		row.BaseRef,
159
+		runs,
160
+	)
161
+}
162
+
163
+func actionsSuiteViewFromGetRow(row checksdb.GetCheckSuiteForRepoRow, runs []checksdb.CheckRun) actionsSuiteView {
164
+	return actionsSuiteViewFromParts(
165
+		row.ID,
166
+		row.HeadSha,
167
+		row.AppSlug,
168
+		row.Status,
169
+		row.Conclusion,
170
+		row.CreatedAt.Time,
171
+		row.UpdatedAt.Time,
172
+		row.PullNumber,
173
+		row.PullTitle,
174
+		row.PullAuthorUsername,
175
+		row.HeadRef,
176
+		row.BaseRef,
177
+		runs,
178
+	)
179
+}
180
+
181
+func actionsSuiteViewFromParts(
182
+	id int64,
183
+	headSHA string,
184
+	appSlug string,
185
+	status checksdb.CheckStatus,
186
+	conclusion checksdb.NullCheckConclusion,
187
+	createdAt time.Time,
188
+	updatedAt time.Time,
189
+	pullNumber int64,
190
+	pullTitle string,
191
+	pullAuthorUsername string,
192
+	headRef string,
193
+	baseRef string,
194
+	runs []checksdb.CheckRun,
195
+) actionsSuiteView {
196
+	title := pullTitle
197
+	if title == "" {
198
+		title = appSlug + " checks for " + shortSHA(headSHA)
199
+	}
200
+	stateText, stateClass, stateIcon := actionState(status, conclusion)
201
+	runViews := make([]actionsRunView, 0, len(runs))
202
+	annotationCount := 0
203
+	for _, run := range runs {
204
+		view := actionsRunViewFromRun(run)
205
+		if view.SummaryHTML != "" {
206
+			annotationCount++
207
+		}
208
+		runViews = append(runViews, view)
209
+	}
210
+	return actionsSuiteView{
211
+		ID:                 id,
212
+		AppSlug:            appSlug,
213
+		Title:              title,
214
+		HeadSha:            headSHA,
215
+		HeadShaShort:       shortSHA(headSHA),
216
+		PullNumber:         pullNumber,
217
+		PullAuthorUsername: pullAuthorUsername,
218
+		HeadRef:            headRef,
219
+		BaseRef:            baseRef,
220
+		RunCount:           len(runs),
221
+		StateText:          stateText,
222
+		StateClass:         stateClass,
223
+		StateIcon:          stateIcon,
224
+		CreatedAt:          createdAt,
225
+		UpdatedAt:          updatedAt,
226
+		Duration:           actionSuiteDuration(runs, createdAt, updatedAt),
227
+		Runs:               runViews,
228
+		AnnotationCount:    annotationCount,
229
+	}
230
+}
231
+
232
+func actionsRunViewFromRun(run checksdb.CheckRun) actionsRunView {
233
+	stateText, stateClass, stateIcon := actionState(run.Status, run.Conclusion)
234
+	start := run.CreatedAt.Time
235
+	if run.StartedAt.Valid {
236
+		start = run.StartedAt.Time
237
+	}
238
+	end := run.UpdatedAt.Time
239
+	if run.CompletedAt.Valid {
240
+		end = run.CompletedAt.Time
241
+	}
242
+	return actionsRunView{
243
+		ID:          run.ID,
244
+		Name:        run.Name,
245
+		StateText:   stateText,
246
+		StateClass:  stateClass,
247
+		StateIcon:   stateIcon,
248
+		Duration:    formatDuration(end.Sub(start)),
249
+		CompletedAt: end,
250
+		DetailsURL:  run.DetailsUrl,
251
+		SummaryHTML: renderCheckSummary(run.Output),
252
+	}
253
+}
254
+
255
+func actionState(status checksdb.CheckStatus, conclusion checksdb.NullCheckConclusion) (string, string, string) {
256
+	if !conclusion.Valid {
257
+		switch status {
258
+		case checksdb.CheckStatusCompleted:
259
+			return "Completed", "neutral", "check-circle"
260
+		case checksdb.CheckStatusInProgress:
261
+			return "In progress", "pending", "dot-fill"
262
+		case checksdb.CheckStatusQueued, checksdb.CheckStatusPending:
263
+			return "Queued", "pending", "dot-fill"
264
+		default:
265
+			return string(status), "neutral", "dot-fill"
266
+		}
267
+	}
268
+	switch conclusion.CheckConclusion {
269
+	case checksdb.CheckConclusionSuccess, checksdb.CheckConclusionSkipped, checksdb.CheckConclusionNeutral:
270
+		return "Success", "success", "check-circle-fill"
271
+	case checksdb.CheckConclusionFailure, checksdb.CheckConclusionTimedOut, checksdb.CheckConclusionActionRequired:
272
+		return "Failure", "failure", "x-circle-fill"
273
+	case checksdb.CheckConclusionCancelled, checksdb.CheckConclusionStale:
274
+		return "Cancelled", "neutral", "x-circle"
275
+	default:
276
+		return string(conclusion.CheckConclusion), "neutral", "dot-fill"
277
+	}
278
+}
279
+
280
+func actionSuiteDuration(runs []checksdb.CheckRun, createdAt, updatedAt time.Time) string {
281
+	if len(runs) == 0 {
282
+		return formatDuration(updatedAt.Sub(createdAt))
283
+	}
284
+	var start, end time.Time
285
+	for _, run := range runs {
286
+		runStart := run.CreatedAt.Time
287
+		if run.StartedAt.Valid {
288
+			runStart = run.StartedAt.Time
289
+		}
290
+		runEnd := run.UpdatedAt.Time
291
+		if run.CompletedAt.Valid {
292
+			runEnd = run.CompletedAt.Time
293
+		}
294
+		if start.IsZero() || runStart.Before(start) {
295
+			start = runStart
296
+		}
297
+		if end.IsZero() || runEnd.After(end) {
298
+			end = runEnd
299
+		}
300
+	}
301
+	return formatDuration(end.Sub(start))
302
+}
303
+
304
+func formatDuration(d time.Duration) string {
305
+	if d <= 0 {
306
+		return "—"
307
+	}
308
+	if d < time.Minute {
309
+		return strconv.Itoa(int(d.Seconds())) + "s"
310
+	}
311
+	if d < time.Hour {
312
+		mins := int(d / time.Minute)
313
+		secs := int((d % time.Minute) / time.Second)
314
+		if secs == 0 {
315
+			return strconv.Itoa(mins) + "m"
316
+		}
317
+		return strconv.Itoa(mins) + "m " + strconv.Itoa(secs) + "s"
318
+	}
319
+	hours := int(d / time.Hour)
320
+	mins := int((d % time.Hour) / time.Minute)
321
+	return strconv.Itoa(hours) + "h " + strconv.Itoa(mins) + "m"
322
+}
323
+
324
+func shortSHA(sha string) string {
325
+	if len(sha) <= 7 {
326
+		return sha
327
+	}
328
+	return sha[:7]
329
+}
internal/web/handlers/repo/deferred_tabs.gomodified
@@ -24,19 +24,6 @@ type repoDeferredSection struct {
2424
 	Body   string
2525
 }
2626
 
27
-func (h *Handlers) repoTabActions(w http.ResponseWriter, r *http.Request) {
28
-	h.renderDeferredRepoTab(w, r, repoDeferredTab{
29
-		Active:      "actions",
30
-		Heading:     "Actions",
31
-		Description: "Automate your development workflow with repository workflows and check runs.",
32
-		Icon:        "play",
33
-		Sections: []repoDeferredSection{
34
-			{Anchor: "all-workflows", Title: "All workflows", Body: "Workflow execution is parked for the Actions sprint. Check runs posted by external systems still appear on pull requests."},
35
-			{Anchor: "workflows", Title: "Workflows", Body: "Add workflow files later under .shithub/workflows to run jobs on push and pull request events."},
36
-		},
37
-	})
38
-}
39
-
4027
 func (h *Handlers) repoTabProjects(w http.ResponseWriter, r *http.Request) {
4128
 	h.renderDeferredRepoTab(w, r, repoDeferredTab{
4229
 		Active:      "projects",
internal/web/handlers/repo/repo.gomodified
@@ -118,6 +118,7 @@ func (h *Handlers) MountNew(r chi.Router) {
118118
 // two-segment route doesn't collide with the /{username} catch-all from S09;
119119
 // caller is responsible for ordering this BEFORE /{username}.
120120
 func (h *Handlers) MountRepoHome(r chi.Router) {
121
+	r.Get("/{owner}/{repo}/actions/runs/{suiteID}", h.repoActionRun)
121122
 	r.Get("/{owner}/{repo}/actions", h.repoTabActions)
122123
 	r.Get("/{owner}/{repo}/projects", h.repoTabProjects)
123124
 	r.Get("/{owner}/{repo}/wiki", h.repoTabWiki)
internal/web/render/octicons.gomodified
@@ -61,8 +61,12 @@ func BuiltinOcticons() OcticonResolver {
6161
 			`><path d="M2.5 1.75v11.5c0 .138.112.25.25.25h3.17a.75.75 0 0 1 0 1.5H2.75A1.75 1.75 0 0 1 1 13.25V1.75C1 .784 1.784 0 2.75 0h8.5C12.216 0 13 .784 13 1.75v7.736a.75.75 0 0 1-1.5 0V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13.274 9.537-4.557 4.45a.75.75 0 0 1-1.055-.008l-1.943-1.95a.75.75 0 0 1 1.062-1.058l1.419 1.425 4.026-3.932a.75.75 0 1 1 1.048 1.074ZM4.75 4h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM4 7.75A.75.75 0 0 1 4.75 7h2a.75.75 0 0 1 0 1.5h-2A.75.75 0 0 1 4 7.75Z"/></svg>`),
6262
 		"check-circle": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
6363
 			`><path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm11.03-1.78a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.97 8.78a.75.75 0 0 1 1.06-1.06l1.22 1.22 2.72-2.72a.75.75 0 0 1 1.06 0Z"/></svg>`),
64
+		"check-circle-fill": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
65
+			`><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"/></svg>`),
6466
 		"x-circle": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
6567
 			`><path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm5.72-2.28a.75.75 0 0 1 1.06 0L8 6.94l1.22-1.22a.75.75 0 1 1 1.06 1.06L9.06 8l1.22 1.22a.75.75 0 1 1-1.06 1.06L8 9.06l-1.22 1.22a.75.75 0 1 1-1.06-1.06L6.94 8 5.72 6.78a.75.75 0 0 1 0-1.06Z"/></svg>`),
68
+		"x-circle-fill": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
69
+			`><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16ZM5.72 5.72a.75.75 0 0 0 0 1.06L6.94 8 5.72 9.22a.75.75 0 1 0 1.06 1.06L8 9.06l1.22 1.22a.75.75 0 1 0 1.06-1.06L9.06 8l1.22-1.22a.75.75 0 1 0-1.06-1.06L8 6.94 6.78 5.72a.75.75 0 0 0-1.06 0Z"/></svg>`),
6670
 		"rocket": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
6771
 			`><path d="M14.064.66a.75.75 0 0 1 1.276.588c-.32 2.07-1.03 4.72-2.52 6.95-.72 1.08-1.65 2.08-2.84 2.8l.27 2.18a.75.75 0 0 1-.22.61l-1.5 1.5a.75.75 0 0 1-1.25-.34l-.65-2.61-2.97-.74-2.61-.65a.75.75 0 0 1-.34-1.25l1.5-1.5a.75.75 0 0 1 .61-.22l2.18.27c.72-1.19 1.72-2.12 2.8-2.84C10.03 3.92 12.68 3.21 14.75 2.89a.75.75 0 0 1-.686-2.23ZM9.5 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 14.25c0-.966.784-1.75 1.75-1.75h.5a.75.75 0 0 1 0 1.5h-.5a.25.25 0 0 0-.25.25v.5a.75.75 0 0 1-1.5 0Z"/></svg>`),
6872
 		"git-branch": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
@@ -85,6 +89,10 @@ func BuiltinOcticons() OcticonResolver {
8589
 			`><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v6.5A1.75 1.75 0 0 1 13.25 11H8.06l-3.31 2.48A.75.75 0 0 1 3.5 12.88V11h-.75A1.75 1.75 0 0 1 1 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v6.5c0 .138.112.25.25.25h1.5a.75.75 0 0 1 .75.75v1.13l2.36-1.77a.75.75 0 0 1 .45-.15h5.44a.25.25 0 0 0 .25-.25v-6.5a.25.25 0 0 0-.25-.25Z"/></svg>`),
8690
 		"history": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
8791
 			`><path d="M1.643 3.143.427 1.927A.25.25 0 0 0 0 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 0 0 .177-.427L2.715 4.215A6.5 6.5 0 1 1 8 14.5a.75.75 0 0 0 0 1.5 8 8 0 1 0-6.357-12.857ZM7.25 4.75a.75.75 0 0 1 1.5 0v3.19l2.03 2.03a.75.75 0 1 1-1.06 1.06L7.47 8.78a.75.75 0 0 1-.22-.53Z"/></svg>`),
92
+		"calendar": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
93
+			`><path d="M4.75 0a.75.75 0 0 1 .75.75V2h5V.75a.75.75 0 0 1 1.5 0V2h1.25c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25V3.75C1 2.784 1.784 2 2.75 2H4V.75A.75.75 0 0 1 4.75 0ZM2.5 7.5v6.75c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V7.5Zm10.75-4H2.75a.25.25 0 0 0-.25.25V6h11V3.75a.25.25 0 0 0-.25-.25Z"/></svg>`),
94
+		"stopwatch": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
95
+			`><path d="M5.75.75A.75.75 0 0 1 6.5 0h3a.75.75 0 0 1 0 1.5h-.75v1l-.001.041a6.724 6.724 0 0 1 3.464 1.435l.007-.006.75-.75a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734l-.75.75-.006.007a6.75 6.75 0 1 1-10.548 0L2.72 5.03l-.75-.75a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018l.75.75.007.006A6.72 6.72 0 0 1 7.25 2.541V1.5H6.5a.75.75 0 0 1-.75-.75ZM8 14.5a5.25 5.25 0 1 0-.001-10.501A5.25 5.25 0 0 0 8 14.5Zm.389-6.7 1.33-1.33a.75.75 0 1 1 1.061 1.06L9.45 8.861A1.503 1.503 0 0 1 8 10.75a1.499 1.499 0 1 1 .389-2.95Z"/></svg>`),
8896
 		"gear": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
8997
 			`><path d="M8 0a1.5 1.5 0 0 1 1.45 1.13l.21.83c.23.08.45.18.66.3l.75-.44a1.5 1.5 0 0 1 1.93.3l.88.88a1.5 1.5 0 0 1 .3 1.93l-.44.75c.12.21.22.43.3.66l.83.21A1.5 1.5 0 0 1 16 8a1.5 1.5 0 0 1-1.13 1.45l-.83.21c-.08.23-.18.45-.3.66l.44.75a1.5 1.5 0 0 1-.3 1.93l-.88.88a1.5 1.5 0 0 1-1.93.3l-.75-.44c-.21.12-.43.22-.66.3l-.21.83A1.5 1.5 0 0 1 8 16a1.5 1.5 0 0 1-1.45-1.13l-.21-.83a5.36 5.36 0 0 1-.66-.3l-.75.44a1.5 1.5 0 0 1-1.93-.3L2.12 13a1.5 1.5 0 0 1-.3-1.93l.44-.75a5.36 5.36 0 0 1-.3-.66l-.83-.21A1.5 1.5 0 0 1 0 8c0-.69.47-1.29 1.13-1.45l.83-.21c.08-.23.18-.45.3-.66l-.44-.75a1.5 1.5 0 0 1 .3-1.93L3 2.12a1.5 1.5 0 0 1 1.93-.3l.75.44c.21-.12.43-.22.66-.3l.21-.83A1.5 1.5 0 0 1 8 0Zm0 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>`),
9098
 		"lock": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
@@ -131,6 +139,16 @@ func BuiltinOcticons() OcticonResolver {
131139
 			`><path d="M7.775 3.275a.75.75 0 0 1 0 1.06L4.53 7.58a2.25 2.25 0 1 0 3.182 3.182l1.47-1.47a.75.75 0 0 1 1.06 1.061l-1.47 1.47A3.75 3.75 0 1 1 3.47 6.52l3.245-3.245a.75.75 0 0 1 1.06 0Zm.45 9.45a.75.75 0 0 1 0-1.06l3.245-3.245a2.25 2.25 0 1 0-3.182-3.182l-1.47 1.47a.75.75 0 0 1-1.06-1.061l1.47-1.47A3.75 3.75 0 1 1 12.53 9.48l-3.245 3.245a.75.75 0 0 1-1.06 0Z"/></svg>`),
132140
 		"dot-fill": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
133141
 			`><path d="M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"/></svg>`),
142
+		"kebab-horizontal": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
143
+			`><path d="M8 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/></svg>`),
144
+		"workflow": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
145
+			`><path d="M0 2.75C0 1.784.784 1 1.75 1h2.5C5.216 1 6 1.784 6 2.75v2.5A1.75 1.75 0 0 1 4.25 7H3.5v2h6.75A1.75 1.75 0 0 1 12 10.75V12h.75a.75.75 0 0 1 0 1.5H12v.75A1.75 1.75 0 0 1 10.25 16h-2.5A1.75 1.75 0 0 1 6 14.25v-2.5C6 10.784 6.784 10 7.75 10h2.5a.25.25 0 0 1 .25.25V10.75a.25.25 0 0 0-.25-.25H3.5v3.75a.75.75 0 0 1-1.5 0V7h-.25A1.75 1.75 0 0 1 0 5.25Zm1.75-.25a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25Zm6 9a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25Z"/></svg>`),
146
+		"screen-full": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
147
+			`><path d="M1.75 10a.75.75 0 0 1 .75.75v2.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 0 1.5h-2.5A1.75 1.75 0 0 1 1 13.25v-2.5a.75.75 0 0 1 .75-.75Zm12.5 0a.75.75 0 0 1 .75.75v2.5A1.75 1.75 0 0 1 13.25 15h-2.5a.75.75 0 0 1 0-1.5h2.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 .75-.75ZM2.75 2.5a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0v-2.5C1 1.784 1.784 1 2.75 1h2.5a.75.75 0 0 1 0 1.5ZM10 1.75a.75.75 0 0 1 .75-.75h2.5c.966 0 1.75.784 1.75 1.75v2.5a.75.75 0 0 1-1.5 0v-2.5a.25.25 0 0 0-.25-.25h-2.5a.75.75 0 0 1-.75-.75Z"/></svg>`),
148
+		"dash": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
149
+			`><path d="M2 7.75A.75.75 0 0 1 2.75 7h10a.75.75 0 0 1 0 1.5h-10A.75.75 0 0 1 2 7.75Z"/></svg>`),
150
+		"plus": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
151
+			`><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>`),
134152
 		"milestone": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
135153
 			`><path d="M8 0a.75.75 0 0 1 .75.75V2h3.5c.414 0 .75.336.75.75v4.5a.75.75 0 0 1-.75.75h-3.5v1h4.5c.414 0 .75.336.75.75v4.5a.75.75 0 0 1-.75.75h-5v.25a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM8.75 3.5v3h2.75v-3Zm0 7v3h3.75v-3Z"/></svg>`),
136154
 		// S29: notification bell for the top-bar inbox link.
internal/web/static/css/shithub.cssmodified
@@ -2158,6 +2158,378 @@ button.shithub-repo-action {
21582158
     white-space: nowrap;
21592159
   }
21602160
 }
2161
+
2162
+/* ========== Repository Actions ========== */
2163
+.shithub-actions-page,
2164
+.shithub-actions-run-layout {
2165
+  display: grid;
2166
+  grid-template-columns: 18rem minmax(0, 1fr);
2167
+  gap: 1.5rem;
2168
+  max-width: 1280px;
2169
+  margin: 0 auto;
2170
+  padding: 1.5rem 1rem;
2171
+}
2172
+.shithub-actions-sidebar {
2173
+  align-self: start;
2174
+  display: flex;
2175
+  flex-direction: column;
2176
+  gap: 0.25rem;
2177
+}
2178
+.shithub-actions-sidebar-section {
2179
+  margin-top: 1rem;
2180
+  padding-top: 0.75rem;
2181
+  border-top: 1px solid var(--border-default);
2182
+}
2183
+.shithub-actions-sidebar-section h2 {
2184
+  margin: 0 0 0.4rem;
2185
+  padding: 0 0.55rem;
2186
+  color: var(--fg-muted);
2187
+  font-size: 0.78rem;
2188
+  font-weight: 600;
2189
+}
2190
+.shithub-actions-sidebar-section p {
2191
+  margin: 0;
2192
+  padding: 0.25rem 0.55rem;
2193
+  color: var(--fg-muted);
2194
+  font-size: 0.85rem;
2195
+}
2196
+.shithub-actions-nav-item {
2197
+  display: grid;
2198
+  grid-template-columns: 1rem minmax(0, 1fr) auto;
2199
+  align-items: center;
2200
+  gap: 0.5rem;
2201
+  min-height: 2rem;
2202
+  padding: 0.35rem 0.55rem;
2203
+  border-radius: 6px;
2204
+  color: var(--fg-default);
2205
+  font-size: 0.9rem;
2206
+  text-decoration: none;
2207
+}
2208
+.shithub-actions-nav-item:hover,
2209
+.shithub-actions-nav-item.is-active {
2210
+  background: var(--canvas-subtle);
2211
+  text-decoration: none;
2212
+}
2213
+.shithub-actions-nav-item span:nth-child(2) {
2214
+  overflow: hidden;
2215
+  text-overflow: ellipsis;
2216
+  white-space: nowrap;
2217
+}
2218
+.shithub-actions-nav-count {
2219
+  color: var(--fg-muted);
2220
+  font-size: 0.78rem;
2221
+}
2222
+.shithub-actions-main,
2223
+.shithub-actions-run-main {
2224
+  min-width: 0;
2225
+}
2226
+.shithub-actions-head {
2227
+  display: flex;
2228
+  align-items: center;
2229
+  justify-content: space-between;
2230
+  gap: 1rem;
2231
+  margin-bottom: 1rem;
2232
+}
2233
+.shithub-actions-head h1 {
2234
+  margin: 0;
2235
+  font-size: 1.5rem;
2236
+}
2237
+.shithub-actions-head p {
2238
+  margin: 0.2rem 0 0;
2239
+  color: var(--fg-muted);
2240
+}
2241
+.shithub-actions-filters {
2242
+  display: grid;
2243
+  grid-template-columns: minmax(16rem, 1fr) repeat(3, auto);
2244
+  gap: 0.5rem;
2245
+  margin-bottom: 1rem;
2246
+}
2247
+.shithub-actions-search {
2248
+  display: flex;
2249
+  align-items: center;
2250
+  gap: 0.45rem;
2251
+  min-width: 0;
2252
+  padding: 0.35rem 0.55rem;
2253
+  border: 1px solid var(--border-default);
2254
+  border-radius: 6px;
2255
+  background: var(--canvas-default);
2256
+  color: var(--fg-muted);
2257
+}
2258
+.shithub-actions-search input {
2259
+  width: 100%;
2260
+  min-width: 0;
2261
+  border: 0;
2262
+  outline: 0;
2263
+  background: transparent;
2264
+  color: var(--fg-muted);
2265
+}
2266
+.shithub-actions-filter-button {
2267
+  display: inline-flex;
2268
+  align-items: center;
2269
+  gap: 0.35rem;
2270
+  padding: 0.35rem 0.7rem;
2271
+  border: 1px solid var(--border-default);
2272
+  border-radius: 6px;
2273
+  background: var(--canvas-default);
2274
+  color: var(--fg-default);
2275
+  font-size: 0.88rem;
2276
+  font-weight: 600;
2277
+}
2278
+.shithub-actions-runs {
2279
+  overflow: hidden;
2280
+  border: 1px solid var(--border-default);
2281
+  border-radius: 6px;
2282
+  background: var(--canvas-default);
2283
+}
2284
+.shithub-actions-run-row {
2285
+  display: grid;
2286
+  grid-template-columns: minmax(0, 1.6fr) minmax(9rem, 0.7fr) minmax(13rem, 0.8fr);
2287
+  gap: 1rem;
2288
+  align-items: center;
2289
+  padding: 0.9rem 1rem;
2290
+  border-top: 1px solid var(--border-default);
2291
+}
2292
+.shithub-actions-run-row:first-child { border-top: 0; }
2293
+.shithub-actions-run-primary,
2294
+.shithub-actions-run-branch,
2295
+.shithub-actions-run-meta {
2296
+  min-width: 0;
2297
+}
2298
+.shithub-actions-run-title {
2299
+  display: flex;
2300
+  align-items: flex-start;
2301
+  gap: 0.55rem;
2302
+  min-width: 0;
2303
+  color: var(--fg-default);
2304
+  font-size: 1rem;
2305
+  font-weight: 600;
2306
+  text-decoration: none;
2307
+}
2308
+.shithub-actions-run-title span:last-child {
2309
+  overflow: hidden;
2310
+  text-overflow: ellipsis;
2311
+  white-space: nowrap;
2312
+}
2313
+.shithub-actions-run-title:hover span:last-child { text-decoration: underline; }
2314
+.shithub-actions-run-primary p {
2315
+  margin: 0.25rem 0 0 1.55rem;
2316
+  color: var(--fg-muted);
2317
+  font-size: 0.86rem;
2318
+}
2319
+.shithub-actions-run-meta {
2320
+  display: flex;
2321
+  align-items: center;
2322
+  justify-content: flex-end;
2323
+  gap: 0.75rem;
2324
+  color: var(--fg-muted);
2325
+  font-size: 0.82rem;
2326
+  white-space: nowrap;
2327
+}
2328
+.shithub-actions-run-meta span {
2329
+  display: inline-flex;
2330
+  align-items: center;
2331
+  gap: 0.25rem;
2332
+}
2333
+.shithub-actions-state {
2334
+  display: inline-flex;
2335
+  align-items: center;
2336
+  justify-content: center;
2337
+  color: var(--fg-muted);
2338
+}
2339
+.shithub-actions-state-success { color: #1a7f37; }
2340
+.shithub-actions-state-failure { color: #cf222e; }
2341
+.shithub-actions-state-pending { color: #9a6700; }
2342
+.shithub-actions-state-neutral { color: var(--fg-muted); }
2343
+.shithub-actions-empty {
2344
+  padding: 4rem 1rem;
2345
+  border: 1px solid var(--border-default);
2346
+  border-radius: 6px;
2347
+  background: var(--canvas-default);
2348
+  text-align: center;
2349
+}
2350
+.shithub-actions-empty-icon {
2351
+  display: inline-flex;
2352
+  align-items: center;
2353
+  justify-content: center;
2354
+  width: 3rem;
2355
+  height: 3rem;
2356
+  margin-bottom: 1rem;
2357
+  border: 1px solid var(--border-default);
2358
+  border-radius: 999px;
2359
+  color: var(--fg-muted);
2360
+}
2361
+.shithub-actions-empty h2 {
2362
+  margin: 0;
2363
+  font-size: 1.25rem;
2364
+}
2365
+.shithub-actions-empty p {
2366
+  margin: 0.5rem auto 0;
2367
+  max-width: 34rem;
2368
+  color: var(--fg-muted);
2369
+}
2370
+.shithub-actions-run-page {
2371
+  max-width: 1280px;
2372
+  margin: 0 auto;
2373
+  padding: 1.5rem 1rem;
2374
+}
2375
+.shithub-actions-run-head {
2376
+  display: flex;
2377
+  align-items: flex-start;
2378
+  justify-content: space-between;
2379
+  gap: 1rem;
2380
+  padding-bottom: 1rem;
2381
+  border-bottom: 1px solid var(--border-default);
2382
+}
2383
+.shithub-actions-back {
2384
+  display: inline-block;
2385
+  margin-bottom: 0.4rem;
2386
+  color: var(--fg-muted);
2387
+  font-size: 0.86rem;
2388
+}
2389
+.shithub-actions-run-head h1 {
2390
+  display: flex;
2391
+  align-items: center;
2392
+  gap: 0.45rem;
2393
+  margin: 0;
2394
+  font-size: 1.5rem;
2395
+  line-height: 1.25;
2396
+}
2397
+.shithub-actions-run-head h1 span:last-child {
2398
+  color: var(--fg-muted);
2399
+  font-weight: 400;
2400
+}
2401
+.shithub-actions-run-head p {
2402
+  margin: 0.35rem 0 0;
2403
+  color: var(--fg-muted);
2404
+}
2405
+.shithub-actions-run-head-actions {
2406
+  display: flex;
2407
+  align-items: center;
2408
+  gap: 0.5rem;
2409
+  flex-shrink: 0;
2410
+}
2411
+.shithub-actions-run-status {
2412
+  pointer-events: none;
2413
+}
2414
+.shithub-actions-run-layout {
2415
+  grid-template-columns: 14rem minmax(0, 1fr);
2416
+  padding: 1.5rem 0 0;
2417
+}
2418
+.shithub-actions-summary-strip {
2419
+  display: grid;
2420
+  grid-template-columns: minmax(0, 1.6fr) repeat(3, minmax(7rem, 1fr));
2421
+  gap: 1rem;
2422
+  margin-bottom: 1rem;
2423
+  padding: 1rem;
2424
+  border: 1px solid var(--border-default);
2425
+  border-radius: 6px;
2426
+  background: var(--canvas-default);
2427
+}
2428
+.shithub-actions-summary-strip > div {
2429
+  display: flex;
2430
+  flex-direction: column;
2431
+  gap: 0.2rem;
2432
+}
2433
+.shithub-actions-summary-strip span {
2434
+  color: var(--fg-muted);
2435
+  font-size: 0.82rem;
2436
+}
2437
+.shithub-actions-workflow-card,
2438
+.shithub-actions-annotations {
2439
+  border: 1px solid var(--border-default);
2440
+  border-radius: 6px;
2441
+  background: var(--canvas-default);
2442
+}
2443
+.shithub-actions-workflow-card > header,
2444
+.shithub-actions-annotations > header {
2445
+  padding: 1rem;
2446
+  border-bottom: 1px solid var(--border-default);
2447
+}
2448
+.shithub-actions-workflow-card h2,
2449
+.shithub-actions-annotations h2 {
2450
+  margin: 0;
2451
+  font-size: 1rem;
2452
+}
2453
+.shithub-actions-workflow-card p,
2454
+.shithub-actions-annotations p {
2455
+  margin: 0.25rem 0 0;
2456
+  color: var(--fg-muted);
2457
+}
2458
+.shithub-actions-workflow-graph {
2459
+  position: relative;
2460
+  min-height: 13rem;
2461
+  padding: 2.5rem;
2462
+  background: var(--canvas-inset);
2463
+}
2464
+.shithub-actions-workflow-stage {
2465
+  display: flex;
2466
+  flex-direction: column;
2467
+  gap: 0.75rem;
2468
+  max-width: 20rem;
2469
+}
2470
+.shithub-actions-job-card {
2471
+  display: grid;
2472
+  grid-template-columns: 1rem minmax(0, 1fr) auto;
2473
+  align-items: center;
2474
+  gap: 0.55rem;
2475
+  padding: 0.7rem;
2476
+  border: 1px solid var(--border-default);
2477
+  border-radius: 6px;
2478
+  background: var(--canvas-default);
2479
+  color: var(--fg-default);
2480
+  text-decoration: none;
2481
+}
2482
+.shithub-actions-job-card:hover { text-decoration: none; }
2483
+.shithub-actions-job-card strong {
2484
+  overflow: hidden;
2485
+  text-overflow: ellipsis;
2486
+  white-space: nowrap;
2487
+}
2488
+.shithub-actions-job-card span:last-child {
2489
+  color: var(--fg-muted);
2490
+  font-size: 0.82rem;
2491
+}
2492
+.shithub-actions-graph-controls {
2493
+  position: absolute;
2494
+  right: 0.75rem;
2495
+  bottom: 0.75rem;
2496
+  display: flex;
2497
+  gap: 0.35rem;
2498
+}
2499
+.shithub-actions-annotations {
2500
+  margin-top: 1rem;
2501
+}
2502
+.shithub-actions-annotation-list {
2503
+  display: flex;
2504
+  flex-direction: column;
2505
+}
2506
+.shithub-actions-annotation {
2507
+  display: grid;
2508
+  grid-template-columns: 1rem minmax(0, 1fr);
2509
+  gap: 0.6rem;
2510
+  padding: 0.9rem 1rem;
2511
+  border-top: 1px solid var(--border-default);
2512
+}
2513
+.shithub-actions-annotation > svg {
2514
+  margin-top: 0.15rem;
2515
+  color: #9a6700;
2516
+}
2517
+@media (max-width: 900px) {
2518
+  .shithub-actions-page,
2519
+  .shithub-actions-run-layout,
2520
+  .shithub-actions-filters,
2521
+  .shithub-actions-run-row,
2522
+  .shithub-actions-summary-strip {
2523
+    grid-template-columns: 1fr;
2524
+  }
2525
+  .shithub-actions-run-meta,
2526
+  .shithub-actions-run-head {
2527
+    justify-content: flex-start;
2528
+  }
2529
+  .shithub-actions-run-head {
2530
+    flex-direction: column;
2531
+  }
2532
+}
21612533
 .shithub-tree-panel {
21622534
   border: 1px solid var(--border-default);
21632535
   border-radius: 6px;
internal/web/templates/repo/action_run.htmladded
@@ -0,0 +1,118 @@
1
+{{ define "page" -}}
2
+{{ template "repo-header" . }}
3
+<section class="shithub-actions-run-page">
4
+  <header class="shithub-actions-run-head">
5
+    <div>
6
+      <a href="/{{ .Owner }}/{{ .Repo.Name }}/actions" class="shithub-actions-back">← {{ .Run.AppSlug }}</a>
7
+      <h1>
8
+        <span class="shithub-actions-state shithub-actions-state-{{ .Run.StateClass }}">{{ octicon .Run.StateIcon }}</span>
9
+        {{ .Run.Title }} <span>#{{ .Run.ID }}</span>
10
+      </h1>
11
+      <p>
12
+        {{ if .Run.PullNumber }}
13
+          {{ if .Run.PullAuthorUsername }}<a href="/{{ .Run.PullAuthorUsername }}">{{ .Run.PullAuthorUsername }}</a>{{ end }}
14
+          opened <a href="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .Run.PullNumber }}">#{{ .Run.PullNumber }}</a>
15
+          {{ if .Run.HeadRef }}from <a class="shithub-branch-name" href="/{{ .Owner }}/{{ .Repo.Name }}/tree/{{ .Run.HeadRef }}">{{ .Run.HeadRef }}</a>{{ end }}
16
+          {{ if .Run.BaseRef }}into <a class="shithub-branch-name" href="/{{ .Owner }}/{{ .Repo.Name }}/tree/{{ .Run.BaseRef }}">{{ .Run.BaseRef }}</a>{{ end }}
17
+        {{ else }}
18
+          Check suite for <a href="/{{ .Owner }}/{{ .Repo.Name }}/commit/{{ .Run.HeadSha }}"><code>{{ .Run.HeadShaShort }}</code></a>
19
+        {{ end }}
20
+      </p>
21
+    </div>
22
+    <div class="shithub-actions-run-head-actions">
23
+      <span class="shithub-button shithub-actions-run-status shithub-actions-state-{{ .Run.StateClass }}">{{ octicon .Run.StateIcon }} {{ .Run.StateText }}</span>
24
+      <a class="shithub-button" href="/{{ .Owner }}/{{ .Repo.Name }}/tree/{{ if .Run.HeadRef }}{{ .Run.HeadRef }}{{ else }}{{ .Run.HeadSha }}{{ end }}">{{ octicon "code" }} Code</a>
25
+    </div>
26
+  </header>
27
+
28
+  <div class="shithub-actions-run-layout">
29
+    <aside class="shithub-actions-sidebar shithub-actions-run-sidebar" aria-label="Run navigation">
30
+      <a href="#summary" class="shithub-actions-nav-item is-active" aria-current="page">{{ octicon "home" }} <span>Summary</span></a>
31
+      <div class="shithub-actions-sidebar-section">
32
+        <h2>All jobs</h2>
33
+        {{ range .Run.Runs }}
34
+          <a href="#job-{{ .ID }}" class="shithub-actions-nav-item">
35
+            <span class="shithub-actions-state shithub-actions-state-{{ .StateClass }}">{{ octicon .StateIcon }}</span>
36
+            <span>{{ .Name }}</span>
37
+          </a>
38
+        {{ end }}
39
+      </div>
40
+      <div class="shithub-actions-sidebar-section">
41
+        <h2>Run details</h2>
42
+        <span class="shithub-actions-nav-item">{{ octicon "pulse" }} <span>Usage</span></span>
43
+        <span class="shithub-actions-nav-item">{{ octicon "file" }} <span>Workflow file</span></span>
44
+      </div>
45
+    </aside>
46
+
47
+    <div class="shithub-actions-run-main">
48
+      <section id="summary" class="shithub-actions-summary-strip">
49
+        <div>
50
+          <span>Triggered via</span>
51
+          <strong>{{ if .Run.PullNumber }}pull request{{ else }}checks API{{ end }}</strong>
52
+        </div>
53
+        <div>
54
+          <span>Status</span>
55
+          <strong class="shithub-actions-state-{{ .Run.StateClass }}">{{ .Run.StateText }}</strong>
56
+        </div>
57
+        <div>
58
+          <span>Total duration</span>
59
+          <strong>{{ .Run.Duration }}</strong>
60
+        </div>
61
+        <div>
62
+          <span>Artifacts</span>
63
+          <strong>—</strong>
64
+        </div>
65
+      </section>
66
+
67
+      <section class="shithub-actions-workflow-card">
68
+        <header>
69
+          <div>
70
+            <h2><a href="/{{ .Owner }}/{{ .Repo.Name }}/actions">{{ .Run.AppSlug }}.yml</a></h2>
71
+            <p>on: {{ if .Run.PullNumber }}pull_request{{ else }}check_run{{ end }}</p>
72
+          </div>
73
+        </header>
74
+        <div class="shithub-actions-workflow-graph">
75
+          <div class="shithub-actions-workflow-stage">
76
+            {{ range .Run.Runs }}
77
+              <a id="job-{{ .ID }}" href="{{ if .DetailsURL }}{{ .DetailsURL }}{{ else }}#job-{{ .ID }}{{ end }}" class="shithub-actions-job-card">
78
+                <span class="shithub-actions-state shithub-actions-state-{{ .StateClass }}">{{ octicon .StateIcon }}</span>
79
+                <strong>{{ .Name }}</strong>
80
+                <span>{{ .Duration }}</span>
81
+              </a>
82
+            {{ end }}
83
+          </div>
84
+          <div class="shithub-actions-graph-controls" aria-hidden="true">
85
+            <button type="button" class="shithub-icon-button">{{ octicon "screen-full" }}</button>
86
+            <button type="button" class="shithub-icon-button">{{ octicon "dash" }}</button>
87
+            <button type="button" class="shithub-icon-button">{{ octicon "plus" }}</button>
88
+          </div>
89
+        </div>
90
+      </section>
91
+
92
+      <section class="shithub-actions-annotations">
93
+        <header>
94
+          <h2>Annotations</h2>
95
+          <p>{{ .Run.AnnotationCount }} warning{{ if ne .Run.AnnotationCount 1 }}s{{ end }}</p>
96
+        </header>
97
+        {{ if .Run.AnnotationCount }}
98
+          <div class="shithub-actions-annotation-list">
99
+            {{ range .Run.Runs }}
100
+              {{ if .SummaryHTML }}
101
+                <article class="shithub-actions-annotation">
102
+                  {{ octicon "alert" }}
103
+                  <div>
104
+                    <strong>{{ .Name }}</strong>
105
+                    <div class="markdown-body">{{ .SummaryHTML }}</div>
106
+                  </div>
107
+                </article>
108
+              {{ end }}
109
+            {{ end }}
110
+          </div>
111
+        {{ else }}
112
+          <p class="shithub-muted">No annotations were reported for this run.</p>
113
+        {{ end }}
114
+      </section>
115
+    </div>
116
+  </div>
117
+</section>
118
+{{- end }}
internal/web/templates/repo/actions.htmladded
@@ -0,0 +1,83 @@
1
+{{ define "page" -}}
2
+{{ template "repo-header" . }}
3
+<section class="shithub-actions-page">
4
+  <aside class="shithub-actions-sidebar" aria-label="Actions navigation">
5
+    <a href="/{{ .Owner }}/{{ .Repo.Name }}/actions" class="shithub-actions-nav-item is-active" aria-current="page">
6
+      {{ octicon "play" }} <span>All workflows</span>
7
+      <span class="shithub-actions-nav-count">{{ .RunCount }}</span>
8
+    </a>
9
+    <div class="shithub-actions-sidebar-section">
10
+      <h2>Workflows</h2>
11
+      {{ if .Workflows }}
12
+        {{ range .Workflows }}
13
+          <a href="/{{ $.Owner }}/{{ $.Repo.Name }}/actions?workflow={{ .Name }}" class="shithub-actions-nav-item">
14
+            {{ octicon "workflow" }} <span>{{ .Name }}</span>
15
+            <span class="shithub-actions-nav-count">{{ .Count }}</span>
16
+          </a>
17
+        {{ end }}
18
+      {{ else }}
19
+        <p>No workflows have run yet.</p>
20
+      {{ end }}
21
+    </div>
22
+  </aside>
23
+
24
+  <div class="shithub-actions-main">
25
+    <header class="shithub-actions-head">
26
+      <div>
27
+        <h1>All workflows</h1>
28
+        <p>Showing runs from check suites reported for this repository.</p>
29
+      </div>
30
+      <a class="shithub-button" href="/{{ .Owner }}/{{ .Repo.Name }}/settings/branches">{{ octicon "gear" }} Status checks</a>
31
+    </header>
32
+
33
+    <div class="shithub-actions-filters" aria-label="Workflow run filters">
34
+      <div class="shithub-actions-search">
35
+        {{ octicon "search" }}
36
+        <input type="search" placeholder="Filter workflow runs" aria-label="Filter workflow runs" disabled>
37
+      </div>
38
+      <span class="shithub-actions-filter-button">Event {{ octicon "triangle-down" }}</span>
39
+      <span class="shithub-actions-filter-button">Status {{ octicon "triangle-down" }}</span>
40
+      <span class="shithub-actions-filter-button">Branch {{ octicon "triangle-down" }}</span>
41
+    </div>
42
+
43
+    {{ if .Suites }}
44
+      <div class="shithub-actions-runs" aria-label="Workflow runs">
45
+        {{ range .Suites }}
46
+          <article class="shithub-actions-run-row">
47
+            <div class="shithub-actions-run-primary">
48
+              <a href="/{{ $.Owner }}/{{ $.Repo.Name }}/actions/runs/{{ .ID }}" class="shithub-actions-run-title" aria-label="{{ .StateText }}: {{ .Title }}">
49
+                <span class="shithub-actions-state shithub-actions-state-{{ .StateClass }}">{{ octicon .StateIcon }}</span>
50
+                <span>{{ .Title }}</span>
51
+              </a>
52
+              <p>
53
+                <strong>{{ .AppSlug }}</strong>
54
+                #{{ .ID }}:
55
+                {{ if .PullNumber }}
56
+                  Pull request <a href="/{{ $.Owner }}/{{ $.Repo.Name }}/pulls/{{ .PullNumber }}">#{{ .PullNumber }}</a>
57
+                  {{ if .PullAuthorUsername }}opened by <a href="/{{ .PullAuthorUsername }}">{{ .PullAuthorUsername }}</a>{{ end }}
58
+                {{ else }}
59
+                  check suite for <a href="/{{ $.Owner }}/{{ $.Repo.Name }}/commit/{{ .HeadSha }}"><code>{{ .HeadShaShort }}</code></a>
60
+                {{ end }}
61
+              </p>
62
+            </div>
63
+            <div class="shithub-actions-run-branch">
64
+              {{ if .HeadRef }}<a class="shithub-branch-name" href="/{{ $.Owner }}/{{ $.Repo.Name }}/tree/{{ .HeadRef }}">{{ .HeadRef }}</a>{{ else }}<code>{{ .HeadShaShort }}</code>{{ end }}
65
+            </div>
66
+            <div class="shithub-actions-run-meta">
67
+              <span>{{ octicon "calendar" }} <time datetime="{{ .UpdatedAt.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .UpdatedAt }}</time></span>
68
+              <span>{{ octicon "stopwatch" }} {{ .Duration }}</span>
69
+              <button type="button" class="shithub-icon-button" aria-label="Show run options">{{ octicon "kebab-horizontal" }}</button>
70
+            </div>
71
+          </article>
72
+        {{ end }}
73
+      </div>
74
+    {{ else }}
75
+      <section class="shithub-actions-empty">
76
+        <div class="shithub-actions-empty-icon">{{ octicon "play" }}</div>
77
+        <h2>There are no workflow runs yet</h2>
78
+        <p>Check runs posted via the shithub checks API will appear here and on pull requests.</p>
79
+      </section>
80
+    {{ end }}
81
+  </div>
82
+</section>
83
+{{- end }}