feat(ci): add the Actions pages, live job logs and check marks
/<repo>/actions lists the runs, /<repo>/actions/runs/<id> shows a run with its jobs and steps (attempt switcher, cancel and re-run for the owner), and the job page shows the steps and the log with folded groups, a raw log link and a live feed of the lines over Server-Sent Events. The repository event stream gets the views actions, run and job: they carry states only, never log lines. The commit page, the commit and refs lists and the merge request page show the checks of their commit, and the merge request rail says when a job waits for a runner. The web reads and writes through ports.RunService only.
- commit
- 1769e4f987d0ec226c85f70fe6f2e07a0d129f39
- author
- brice · · 2026-10-12 18:39 UTC
- parent
- 1bd4fe7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
54 files · +4219 -261
A internal/adapters/web/actions.go +824 -0
| @@ -0,0 +1,824 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "net/http" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/a-h/templ" | |
| 16 | + | "github.com/bnema/zerowrap" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // The CI pages (ADR 0025): the runs of a repository, one run with its jobs, | |
| 24 | + | // one job with its steps and its log. They read through ports.RunService and | |
| 25 | + | // follow the events RunUpdated and JobUpdated of the repository topic. | |
| 26 | + | // | |
| 27 | + | // GET /<repo>/actions the runs, newest first | |
| 28 | + | // GET /<repo>/actions/runs/<id> one run: jobs and steps | |
| 29 | + | // GET /<repo>/actions/runs/<id>/job/<job id> one job: steps and live log | |
| 30 | + | // GET /<repo>/actions/runs/<id>/job/<job id>/raw the log as text | |
| 31 | + | // POST /<repo>/actions/runs/<id>/cancel | rerun | rerun-failed (the owner) | |
| 32 | + | ||
| 33 | + | const ( | |
| 34 | + | // actionsPageSize is the number of runs one page lists. | |
| 35 | + | actionsPageSize = 30 | |
| 36 | + | // jobLogPageLines is the number of log lines a job page shows at once; the | |
| 37 | + | // rest is reached with ?from=. | |
| 38 | + | jobLogPageLines = 2000 | |
| 39 | + | // rawLogMaxBytes bounds the raw log the server writes to one response. | |
| 40 | + | rawLogMaxBytes = 64 << 20 | |
| 41 | + | ) | |
| 42 | + | ||
| 43 | + | func actionsBase(repo string) string { return repoHref(repo, "actions") } | |
| 44 | + | func runHref(repo string, id int64) string { | |
| 45 | + | return repoHref(repo, "actions", "runs", strconv.FormatInt(id, 10)) | |
| 46 | + | } | |
| 47 | + | func jobHref(repo string, runID int64, jobID string) string { | |
| 48 | + | return runHref(repo, runID) + "/job/" + jobID | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // ciState is the one word shown for a status and a conclusion: what is going | |
| 52 | + | // on, or how it ended. | |
| 53 | + | func ciState(status domain.CIStatus, c domain.CIConclusion) string { | |
| 54 | + | switch status { | |
| 55 | + | case domain.CIQueued: | |
| 56 | + | return "queued" | |
| 57 | + | case domain.CIInProgress: | |
| 58 | + | return "running" | |
| 59 | + | } | |
| 60 | + | switch c { | |
| 61 | + | case domain.CISuccess: | |
| 62 | + | return "success" | |
| 63 | + | case domain.CIFailure: | |
| 64 | + | return "failure" | |
| 65 | + | case domain.CICancelled: | |
| 66 | + | return "cancelled" | |
| 67 | + | case domain.CISkipped: | |
| 68 | + | return "skipped" | |
| 69 | + | case domain.CITimedOut: | |
| 70 | + | return "timed out" | |
| 71 | + | case domain.CINeutral: | |
| 72 | + | return "neutral" | |
| 73 | + | case domain.CIActionRequired: | |
| 74 | + | return "action required" | |
| 75 | + | } | |
| 76 | + | return "done" | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // ciKind groups the states into the few that have an icon and a colour. | |
| 80 | + | func ciKind(status domain.CIStatus, c domain.CIConclusion) string { | |
| 81 | + | switch { | |
| 82 | + | case status == domain.CIQueued: | |
| 83 | + | return "queued" | |
| 84 | + | case status == domain.CIInProgress: | |
| 85 | + | return "running" | |
| 86 | + | case c == domain.CISuccess, c == domain.CINeutral: | |
| 87 | + | return "ok" | |
| 88 | + | case c == domain.CISkipped: | |
| 89 | + | return "skipped" | |
| 90 | + | case c == domain.CICancelled: | |
| 91 | + | return "cancelled" | |
| 92 | + | } | |
| 93 | + | return "bad" | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // duration is a short elapsed time between two instants; empty when the | |
| 97 | + | // start is unknown, running up to now when the end is not. | |
| 98 | + | func duration(start, end, now time.Time) string { | |
| 99 | + | if start.IsZero() { | |
| 100 | + | return "" | |
| 101 | + | } | |
| 102 | + | if end.IsZero() { | |
| 103 | + | end = now | |
| 104 | + | } | |
| 105 | + | d := end.Sub(start).Round(time.Second) | |
| 106 | + | switch { | |
| 107 | + | case d < 0: | |
| 108 | + | return "" | |
| 109 | + | case d < time.Minute: | |
| 110 | + | return fmt.Sprintf("%ds", int(d.Seconds())) | |
| 111 | + | case d < time.Hour: | |
| 112 | + | return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60) | |
| 113 | + | } | |
| 114 | + | return fmt.Sprintf("%dh %02dm", int(d.Hours()), int(d.Minutes())%60) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // RunRow is a run of the list. | |
| 118 | + | type RunRow struct { | |
| 119 | + | ID string | |
| 120 | + | Href string | |
| 121 | + | Kind string // ciKind | |
| 122 | + | State string // ciState | |
| 123 | + | Workflow string | |
| 124 | + | Title string | |
| 125 | + | Ref string | |
| 126 | + | RefHref string | |
| 127 | + | Short string | |
| 128 | + | CHref string | |
| 129 | + | Actor string | |
| 130 | + | When string | |
| 131 | + | WhenAt string | |
| 132 | + | Duration string | |
| 133 | + | Number string | |
| 134 | + | Attempt int32 | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // ActionsPage lists the runs of a repository. | |
| 138 | + | type ActionsPage struct { | |
| 139 | + | Runs []RunRow | |
| 140 | + | // More is the link to the next page, "" on the last. | |
| 141 | + | More string | |
| 142 | + | // NoRunner says that CI has no runner to run on yet. | |
| 143 | + | NoRunner bool | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // StepView is a step of a job. | |
| 147 | + | type StepView struct { | |
| 148 | + | Number int32 | |
| 149 | + | Name string | |
| 150 | + | Kind, State string | |
| 151 | + | Duration string | |
| 152 | + | // LogHref jumps to the lines of the step in the job log. | |
| 153 | + | LogStart, LogEnd int64 | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // JobRow is a job of a run. | |
| 157 | + | type JobRow struct { | |
| 158 | + | ID, Name, Href string | |
| 159 | + | Kind, State string | |
| 160 | + | Runner string | |
| 161 | + | Isolation string | |
| 162 | + | Duration string | |
| 163 | + | Error string | |
| 164 | + | Needs []string | |
| 165 | + | Steps []StepView | |
| 166 | + | Attempt int32 | |
| 167 | + | // Waiting says that the job is queued for a runner that no registered | |
| 168 | + | // runner can be. | |
| 169 | + | Waiting bool | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // RunPage is one run with its jobs. | |
| 173 | + | type RunPage struct { | |
| 174 | + | RunRow | |
| 175 | + | Sha, Error string | |
| 176 | + | Attempt int32 | |
| 177 | + | Latest int32 | |
| 178 | + | Attempts []AttemptLink | |
| 179 | + | Jobs []JobRow | |
| 180 | + | Active bool | |
| 181 | + | // Forms are the owner's actions; nil/empty when the visitor cannot. | |
| 182 | + | CancelAction, RerunAction, RerunFailedAction string | |
| 183 | + | CanRerunFailed bool | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // AttemptLink switches between the attempts of a run. | |
| 187 | + | type AttemptLink struct { | |
| 188 | + | Label, Href string | |
| 189 | + | Current bool | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // LogLineView is a line of a job log, as shown. | |
| 193 | + | type LogLineView struct { | |
| 194 | + | N int64 | |
| 195 | + | Step int32 | |
| 196 | + | Text string | |
| 197 | + | Time string | |
| 198 | + | // Group marks "##[group]" and "##[endgroup]" lines and Error the | |
| 199 | + | // "##[error]" ones; the text is shown without the marker. | |
| 200 | + | Group, End, Error bool | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // JobPage is one job with its steps and its log. | |
| 204 | + | type JobPage struct { | |
| 205 | + | JobRow | |
| 206 | + | RunHref string | |
| 207 | + | RunName string | |
| 208 | + | Run string | |
| 209 | + | Steps []StepView | |
| 210 | + | Lines []LogLineView | |
| 211 | + | // From is the first line shown; Next is the link to the lines after the | |
| 212 | + | // last shown, "" when it is the end; Follow says the log is still being | |
| 213 | + | // written, so the page follows it live. | |
| 214 | + | From int64 | |
| 215 | + | Next string | |
| 216 | + | Prev string | |
| 217 | + | Total int64 | |
| 218 | + | Follow bool | |
| 219 | + | RawHref string | |
| 220 | + | // StreamHref is the live feed of the lines (logStream). | |
| 221 | + | StreamHref string | |
| 222 | + | Note string | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // --- handlers --- | |
| 226 | + | ||
| 227 | + | // actions is GET /<repo>/actions. | |
| 228 | + | func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 229 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 230 | + | if err != nil { | |
| 231 | + | return nil, 0, err | |
| 232 | + | } | |
| 233 | + | token := r.URL.Query().Get("page") | |
| 234 | + | page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), d.repo.Name, ports.ListRunsParams{PageSize: actionsPageSize, PageToken: token}) | |
| 235 | + | if err != nil { | |
| 236 | + | return nil, 0, err | |
| 237 | + | } | |
| 238 | + | now := s.cfg.Now() | |
| 239 | + | p := ActionsPage{} | |
| 240 | + | for _, run := range page.Runs { | |
| 241 | + | p.Runs = append(p.Runs, s.runRow(d.name(), run, now)) | |
| 242 | + | } | |
| 243 | + | if page.NextPageToken != "" { | |
| 244 | + | p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken | |
| 245 | + | } | |
| 246 | + | l := s.repoLayout(ctx, d, "actions") | |
| 247 | + | l.Title = "actions · " + d.name() + " · " + s.instance | |
| 248 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions") | |
| 249 | + | l.Crumbs[0].Current = false | |
| 250 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Current: true}) | |
| 251 | + | return Actions(l, p), http.StatusOK, nil | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow { | |
| 255 | + | row := RunRow{ | |
| 256 | + | ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID), | |
| 257 | + | Kind: ciKind(run.Status, run.Conclusion), State: ciState(run.Status, run.Conclusion), | |
| 258 | + | Workflow: visibleText(run.WorkflowName), Title: visibleText(run.Title), | |
| 259 | + | Ref: visibleText(strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")), | |
| 260 | + | Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: visibleText(run.Actor), | |
| 261 | + | Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt, | |
| 262 | + | } | |
| 263 | + | row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")) | |
| 264 | + | row.When, row.WhenAt = whenOf(now, run.CreatedAt) | |
| 265 | + | return row | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func stepViews(j ports.JobView, now time.Time) []StepView { | |
| 269 | + | ranges := j.StepRanges() | |
| 270 | + | out := make([]StepView, 0, len(j.Steps)) | |
| 271 | + | for i, st := range j.Steps { | |
| 272 | + | status := domain.CIQueued | |
| 273 | + | switch st.State { | |
| 274 | + | case domain.CIStepInProgress: | |
| 275 | + | status = domain.CIInProgress | |
| 276 | + | case domain.CIStepCompleted: | |
| 277 | + | status = domain.CICompleted | |
| 278 | + | } | |
| 279 | + | out = append(out, StepView{ | |
| 280 | + | Number: st.Number, Name: visibleText(st.Name), Kind: ciKind(status, st.Conclusion), State: ciState(status, st.Conclusion), | |
| 281 | + | Duration: duration(st.StartedAt, st.CompletedAt, now), LogStart: ranges[i].Start, LogEnd: ranges[i].End, | |
| 282 | + | }) | |
| 283 | + | } | |
| 284 | + | return out | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func (s *Server) jobRow(repo string, j ports.JobView, now time.Time) JobRow { | |
| 288 | + | return JobRow{ | |
| 289 | + | ID: j.ID, Name: visibleText(j.Name), Href: jobHref(repo, j.RunID, j.ID), Kind: ciKind(j.Status(), j.Conclusion), | |
| 290 | + | State: ciState(j.Status(), j.Conclusion), Runner: visibleText(j.RunnerName), Isolation: string(j.Isolation), | |
| 291 | + | Duration: duration(j.StartedAt, j.CompletedAt, now), Error: visibleText(j.Error), Needs: j.Needs, | |
| 292 | + | Steps: stepViews(j, now), Attempt: j.Attempt, | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func parseRunPath(r *http.Request) (int64, error) { | |
| 297 | + | id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) | |
| 298 | + | if err != nil || id < 1 { | |
| 299 | + | return 0, errNotFound | |
| 300 | + | } | |
| 301 | + | return id, nil | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // run is GET /<repo>/actions/runs/<id>. | |
| 305 | + | func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 306 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 307 | + | if err != nil { | |
| 308 | + | return nil, 0, err | |
| 309 | + | } | |
| 310 | + | id, err := parseRunPath(r) | |
| 311 | + | if err != nil { | |
| 312 | + | return nil, 0, err | |
| 313 | + | } | |
| 314 | + | var attempt int32 | |
| 315 | + | if a := r.URL.Query().Get("attempt"); a != "" { | |
| 316 | + | n, perr := strconv.ParseInt(a, 10, 32) | |
| 317 | + | if perr != nil || n < 1 { | |
| 318 | + | return nil, 0, errNotFound | |
| 319 | + | } | |
| 320 | + | attempt = int32(n) | |
| 321 | + | } | |
| 322 | + | p, err := s.runPage(ctx, d, id, attempt) | |
| 323 | + | if err != nil { | |
| 324 | + | return nil, 0, err | |
| 325 | + | } | |
| 326 | + | l := s.repoLayout(ctx, d, "actions") | |
| 327 | + | l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance | |
| 328 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID) | |
| 329 | + | l.Crumbs[0].Current = false | |
| 330 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Href: actionsBase(d.name())}, Crumb{Label: p.Workflow + " " + p.Number, Current: true}) | |
| 331 | + | l.HasRail = true | |
| 332 | + | return RunDetail(l, p), http.StatusOK, nil | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the run page from the use case results; no rule is decided here | |
| 336 | + | func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int32) (RunPage, error) { | |
| 337 | + | detail, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, attempt) | |
| 338 | + | if err != nil { | |
| 339 | + | return RunPage{}, err | |
| 340 | + | } | |
| 341 | + | now := s.cfg.Now() | |
| 342 | + | run := detail.Run | |
| 343 | + | p := RunPage{ | |
| 344 | + | RunRow: s.runRow(d.name(), run, now), Sha: run.Sha, Error: visibleText(run.Error), | |
| 345 | + | Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted, | |
| 346 | + | } | |
| 347 | + | for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- { | |
| 348 | + | p.Attempts = append(p.Attempts, AttemptLink{ | |
| 349 | + | Label: "attempt " + strconv.Itoa(int(a)), Href: runHref(d.name(), id) + "?attempt=" + strconv.Itoa(int(a)), Current: a == run.Attempt, | |
| 350 | + | }) | |
| 351 | + | } | |
| 352 | + | for _, j := range detail.Jobs { | |
| 353 | + | row := s.jobRow(d.name(), j, now) | |
| 354 | + | if run.Attempt == detail.LatestAttempt { | |
| 355 | + | // An earlier attempt keeps the job as it ran then. | |
| 356 | + | row.Href = jobHref(d.name(), id, j.ID) | |
| 357 | + | } | |
| 358 | + | p.Jobs = append(p.Jobs, row) | |
| 359 | + | } | |
| 360 | + | if SignedIn(ctx) && run.Attempt == detail.LatestAttempt { | |
| 361 | + | base := runHref(d.name(), id) | |
| 362 | + | if p.Active { | |
| 363 | + | p.CancelAction = base + "/cancel" | |
| 364 | + | } else if len(detail.Jobs) > 0 { | |
| 365 | + | p.RerunAction = base + "/rerun" | |
| 366 | + | for _, j := range detail.Jobs { | |
| 367 | + | if j.Conclusion.IsFailure() || j.Conclusion == domain.CICancelled { | |
| 368 | + | p.RerunFailedAction, p.CanRerunFailed = base+"/rerun-failed", true | |
| 369 | + | break | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | return p, nil | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | // job is GET /<repo>/actions/runs/<id>/job/<job id>. | |
| 378 | + | func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 379 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 380 | + | if err != nil { | |
| 381 | + | return nil, 0, err | |
| 382 | + | } | |
| 383 | + | id, err := parseRunPath(r) | |
| 384 | + | if err != nil { | |
| 385 | + | return nil, 0, err | |
| 386 | + | } | |
| 387 | + | jobID := r.PathValue("job") | |
| 388 | + | if runOf, _, _, ok := domain.ParseJobID(jobID); !ok || runOf != id { | |
| 389 | + | return nil, 0, errNotFound | |
| 390 | + | } | |
| 391 | + | var attempt int32 | |
| 392 | + | if a := r.URL.Query().Get("attempt"); a != "" { | |
| 393 | + | n, perr := strconv.ParseInt(a, 10, 32) | |
| 394 | + | if perr != nil || n < 1 { | |
| 395 | + | return nil, 0, errNotFound | |
| 396 | + | } | |
| 397 | + | attempt = int32(n) | |
| 398 | + | } | |
| 399 | + | view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt) | |
| 400 | + | if err != nil { | |
| 401 | + | return nil, 0, err | |
| 402 | + | } | |
| 403 | + | run, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, 0) | |
| 404 | + | if err != nil { | |
| 405 | + | return nil, 0, err | |
| 406 | + | } | |
| 407 | + | now := s.cfg.Now() | |
| 408 | + | var from int64 | |
| 409 | + | if f := r.URL.Query().Get("from"); f != "" { | |
| 410 | + | from, err = strconv.ParseInt(f, 10, 64) | |
| 411 | + | if err != nil || from < 0 { | |
| 412 | + | return nil, 0, errNotFound | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | p := JobPage{ | |
| 416 | + | JobRow: s.jobRow(d.name(), view, now), RunHref: runHref(d.name(), id), RunName: visibleText(run.Run.WorkflowName), | |
| 417 | + | Run: strconv.FormatInt(id, 10), Steps: stepViews(view, now), From: from, Total: view.Lines, | |
| 418 | + | RawHref: jobHref(d.name(), id, jobID) + "/raw", | |
| 419 | + | } | |
| 420 | + | if attempt > 0 { | |
| 421 | + | p.RawHref += "?attempt=" + strconv.Itoa(int(attempt)) | |
| 422 | + | } | |
| 423 | + | p.Follow = !view.Ended() | |
| 424 | + | p.StreamHref = jobHref(d.name(), id, jobID) + "/stream" | |
| 425 | + | lines, next, err := s.readLog(ctx, d.repo.Name, view, from, jobLogPageLines) | |
| 426 | + | if err != nil { | |
| 427 | + | return nil, 0, err | |
| 428 | + | } | |
| 429 | + | p.Lines = lines | |
| 430 | + | if next < view.Lines { | |
| 431 | + | p.Next = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(next, 10) | |
| 432 | + | p.Follow = false // more is waiting before the live end | |
| 433 | + | } | |
| 434 | + | if from > 0 { | |
| 435 | + | p.Prev = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(max(from-jobLogPageLines, 0), 10) | |
| 436 | + | } | |
| 437 | + | if view.Phase == domain.CIPhaseWaiting || view.Phase == domain.CIPhaseReady { | |
| 438 | + | p.Note = "the job has not started: no log yet." | |
| 439 | + | } else if view.Lines == 0 && view.Ended() { | |
| 440 | + | p.Note = "the job wrote no log." | |
| 441 | + | } | |
| 442 | + | l := s.repoLayout(ctx, d, "actions") | |
| 443 | + | l.Title = view.Name + " · run " + p.Run + " · " + d.name() + " · " + s.instance | |
| 444 | + | l.Live = true | |
| 445 | + | l.LiveURL = liveURL("repo:"+d.name(), "view=job&job="+jobID) | |
| 446 | + | l.Crumbs[0].Current = false | |
| 447 | + | l.Crumbs = append(l.Crumbs, | |
| 448 | + | Crumb{Label: "actions", Href: actionsBase(d.name())}, | |
| 449 | + | Crumb{Label: p.RunName + " #" + strconv.FormatInt(run.Run.Number, 10), Href: p.RunHref}, | |
| 450 | + | Crumb{Label: p.Name, Current: true}) | |
| 451 | + | l.HasRail = true | |
| 452 | + | return Job(l, p), http.StatusOK, nil | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | // readLog reads up to max lines from a line number, through the log stream of | |
| 456 | + | // the use case, and returns the line after the last read. | |
| 457 | + | func (s *Server) readLog(ctx context.Context, repo domain.RepoName, j ports.JobView, from int64, max int) ([]LogLineView, int64, error) { | |
| 458 | + | if from > j.Lines || j.Lines == 0 { | |
| 459 | + | return nil, from, nil | |
| 460 | + | } | |
| 461 | + | var out []LogLineView | |
| 462 | + | next := from | |
| 463 | + | ranges := j.StepRanges() | |
| 464 | + | stepOf := func(n int64) int32 { | |
| 465 | + | for i, r := range ranges { | |
| 466 | + | if n >= r.Start && n < r.End { | |
| 467 | + | return j.Steps[i].Number | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | return 0 | |
| 471 | + | } | |
| 472 | + | stop := errors.New("enough") | |
| 473 | + | err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from}, func(b ports.LogBatch) error { | |
| 474 | + | for i, l := range b.Lines { | |
| 475 | + | if len(out) >= max { | |
| 476 | + | return stop | |
| 477 | + | } | |
| 478 | + | n := b.Offset + int64(i) | |
| 479 | + | text := l.Text | |
| 480 | + | v := LogLineView{N: n, Step: stepOf(n), Time: l.Time.UTC().Format("15:04:05")} | |
| 481 | + | switch { | |
| 482 | + | case strings.HasPrefix(text, "##[group]"): | |
| 483 | + | v.Group, text = true, strings.TrimPrefix(text, "##[group]") | |
| 484 | + | case strings.HasPrefix(text, "##[endgroup]"): | |
| 485 | + | v.End, text = true, strings.TrimPrefix(text, "##[endgroup]") | |
| 486 | + | case strings.HasPrefix(text, "##[error]"): | |
| 487 | + | v.Error, text = true, strings.TrimPrefix(text, "##[error]") | |
| 488 | + | } | |
| 489 | + | if l.Continued && len(out) > 0 { | |
| 490 | + | out[len(out)-1].Text += visibleText(text) | |
| 491 | + | next = n + 1 | |
| 492 | + | continue | |
| 493 | + | } | |
| 494 | + | v.Text = visibleText(text) | |
| 495 | + | out = append(out, v) | |
| 496 | + | next = n + 1 | |
| 497 | + | } | |
| 498 | + | return nil | |
| 499 | + | }) | |
| 500 | + | if err != nil && !errors.Is(err, stop) && !errors.Is(err, domain.ErrNotFound) { | |
| 501 | + | return nil, from, err | |
| 502 | + | } | |
| 503 | + | return out, next, nil | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | // rawLog is GET /<repo>/actions/runs/<id>/job/<job id>/raw: the log as text, | |
| 507 | + | // one line per line with its time, joined where the runner cut a long line. | |
| 508 | + | func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) { | |
| 509 | + | ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) | |
| 510 | + | defer cancel() | |
| 511 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 512 | + | if err != nil { | |
| 513 | + | s.fail(ctx, w, r, err) | |
| 514 | + | return | |
| 515 | + | } | |
| 516 | + | id, err := parseRunPath(r) | |
| 517 | + | jobID := r.PathValue("job") | |
| 518 | + | if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id { | |
| 519 | + | s.fail(ctx, w, r, errNotFound) | |
| 520 | + | return | |
| 521 | + | } | |
| 522 | + | var attempt int32 | |
| 523 | + | if a := r.URL.Query().Get("attempt"); a != "" { | |
| 524 | + | n, perr := strconv.ParseInt(a, 10, 32) | |
| 525 | + | if perr != nil || n < 1 { | |
| 526 | + | s.fail(ctx, w, r, errNotFound) | |
| 527 | + | return | |
| 528 | + | } | |
| 529 | + | attempt = int32(n) | |
| 530 | + | } | |
| 531 | + | view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt) | |
| 532 | + | if err != nil { | |
| 533 | + | s.fail(ctx, w, r, err) | |
| 534 | + | return | |
| 535 | + | } | |
| 536 | + | h := w.Header() | |
| 537 | + | h.Set("Content-Type", "text/plain; charset=utf-8") | |
| 538 | + | h.Set("X-Content-Type-Options", "nosniff") | |
| 539 | + | h.Set("Content-Security-Policy", "default-src 'none'; sandbox") | |
| 540 | + | h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="job-%s.log"`, jobID)) | |
| 541 | + | cacheHeaders(ctx, h, "no-cache", sessionCache) | |
| 542 | + | written := 0 | |
| 543 | + | open := false | |
| 544 | + | err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{JobID: jobID, Attempt: attempt}, func(b ports.LogBatch) error { | |
| 545 | + | for _, l := range b.Lines { | |
| 546 | + | if written > rawLogMaxBytes { | |
| 547 | + | return errors.New("log too large") | |
| 548 | + | } | |
| 549 | + | var line string | |
| 550 | + | switch { | |
| 551 | + | case l.Continued && open: | |
| 552 | + | line = l.Text | |
| 553 | + | case open: | |
| 554 | + | line = "\n" + l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text | |
| 555 | + | default: | |
| 556 | + | line = l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text | |
| 557 | + | } | |
| 558 | + | open = true | |
| 559 | + | n, werr := fmt.Fprint(w, line) | |
| 560 | + | written += n | |
| 561 | + | if werr != nil { | |
| 562 | + | return werr | |
| 563 | + | } | |
| 564 | + | } | |
| 565 | + | return nil | |
| 566 | + | }) | |
| 567 | + | if open { | |
| 568 | + | _, _ = fmt.Fprintln(w) | |
| 569 | + | } | |
| 570 | + | if err != nil && !open && errors.Is(err, domain.ErrNotFound) { | |
| 571 | + | _ = view | |
| 572 | + | w.WriteHeader(http.StatusNotFound) | |
| 573 | + | } | |
| 574 | + | } | |
| 575 | + | ||
| 576 | + | // --- the owner's actions --- | |
| 577 | + | ||
| 578 | + | // ciAction wraps a POST of the actions pages: the form gate, the bounded | |
| 579 | + | // context and the mapping of a refusal to a page. | |
| 580 | + | func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc { | |
| 581 | + | return s.mrAction(func(ctx context.Context, r *http.Request) (string, string, error) { | |
| 582 | + | to, err := h(ctx, r) | |
| 583 | + | return to, actionsBase(r.PathValue("repo")), err | |
| 584 | + | }) | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | func (s *Server) runCancel(ctx context.Context, r *http.Request) (string, error) { | |
| 588 | + | name, id, err := s.runTarget(r) | |
| 589 | + | if err != nil { | |
| 590 | + | return "", err | |
| 591 | + | } | |
| 592 | + | if _, err := s.cfg.Runs.CancelRun(ctx, principal(ctx), name, id); err != nil { | |
| 593 | + | return "", err | |
| 594 | + | } | |
| 595 | + | return runHref(name.String(), id), nil | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | func (s *Server) runRerun(failedOnly bool) func(ctx context.Context, r *http.Request) (string, error) { | |
| 599 | + | return func(ctx context.Context, r *http.Request) (string, error) { | |
| 600 | + | name, id, err := s.runTarget(r) | |
| 601 | + | if err != nil { | |
| 602 | + | return "", err | |
| 603 | + | } | |
| 604 | + | if _, err := s.cfg.Runs.RerunRun(ctx, principal(ctx), name, id, failedOnly); err != nil { | |
| 605 | + | return "", err | |
| 606 | + | } | |
| 607 | + | return runHref(name.String(), id), nil | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | func (s *Server) runTarget(r *http.Request) (domain.RepoName, int64, error) { | |
| 612 | + | name, err := domain.ParseRepoName(r.PathValue("repo")) | |
| 613 | + | if err != nil { | |
| 614 | + | return domain.RepoName{}, 0, errNotFound | |
| 615 | + | } | |
| 616 | + | id, err := parseRunPath(r) | |
| 617 | + | return name, id, err | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | func itoa64(n int64) string { return strconv.FormatInt(n, 10) } | |
| 621 | + | ||
| 622 | + | // runnerText names the runner of a job and how it isolated it. | |
| 623 | + | func runnerText(j JobRow) string { | |
| 624 | + | if j.Isolation == "" { | |
| 625 | + | return j.Runner | |
| 626 | + | } | |
| 627 | + | return j.Runner + " (" + j.Isolation + ")" | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | func joinWords(ws []string) string { return strings.Join(ws, ", ") } | |
| 631 | + | ||
| 632 | + | // logClass is the class of a line of the log. | |
| 633 | + | func logClass(ln LogLineView) string { | |
| 634 | + | switch { | |
| 635 | + | case ln.Group: | |
| 636 | + | return "lg group" | |
| 637 | + | case ln.End: | |
| 638 | + | return "lg endgroup" | |
| 639 | + | case ln.Error: | |
| 640 | + | return "lg error" | |
| 641 | + | } | |
| 642 | + | return "lg" | |
| 643 | + | } | |
| 644 | + | ||
| 645 | + | // ChecksSummary is the combined state of the checks of a commit. | |
| 646 | + | type ChecksSummary struct { | |
| 647 | + | Total int | |
| 648 | + | Kind, State string | |
| 649 | + | Label, Title string | |
| 650 | + | Href string | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | // CheckRow is a check of a commit or a merge request. | |
| 654 | + | type CheckRow struct { | |
| 655 | + | Name, Kind, State, Href string | |
| 656 | + | Waiting bool | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | // ChecksSection is the checks of the head of a merge request. | |
| 660 | + | type ChecksSection struct { | |
| 661 | + | Rows []CheckRow | |
| 662 | + | // Note explains the rule that applies to them. | |
| 663 | + | Note string | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | // checksFor reads the checks of a commit as the caller of the request. A page | |
| 667 | + | // shows them when it can: a failure of the read is logged and the page goes on | |
| 668 | + | // without, since the code and the review do not depend on CI. | |
| 669 | + | func (s *Server) checksFor(ctx context.Context, repo domain.RepoName, sha string) []ports.CheckView { | |
| 670 | + | if s.cfg.Runs == nil || sha == "" { | |
| 671 | + | return nil | |
| 672 | + | } | |
| 673 | + | res, err := s.cfg.Runs.ListChecks(ctx, principal(ctx), repo, sha) | |
| 674 | + | if err != nil { | |
| 675 | + | if !errors.Is(err, domain.ErrNotFound) { | |
| 676 | + | log := zerowrap.FromCtx(ctx) | |
| 677 | + | log.Warn().Err(err).Str("repo", repo.String()).Msg("read the checks of a commit") | |
| 678 | + | } | |
| 679 | + | return nil | |
| 680 | + | } | |
| 681 | + | return res.Checks | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | // checksSummary reduces checks to the mark a list shows next to a commit. | |
| 685 | + | func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary { | |
| 686 | + | if len(checks) == 0 { | |
| 687 | + | return ChecksSummary{} | |
| 688 | + | } | |
| 689 | + | plain := make([]domain.CICheck, 0, len(checks)) | |
| 690 | + | passed := 0 | |
| 691 | + | for _, c := range checks { | |
| 692 | + | plain = append(plain, c.CICheck) | |
| 693 | + | if c.Status == domain.CICompleted && c.Conclusion.PassesMerge() { | |
| 694 | + | passed++ | |
| 695 | + | } | |
| 696 | + | } | |
| 697 | + | sum := ChecksSummary{Total: len(checks), Href: repoHref(repo.String(), "actions"), Label: fmt.Sprintf("%d/%d", passed, len(checks))} | |
| 698 | + | switch domain.CombineChecks(plain) { | |
| 699 | + | case domain.CheckStateSuccess: | |
| 700 | + | sum.Kind, sum.State = "ok", "checks passed" | |
| 701 | + | case domain.CheckStateFailure: | |
| 702 | + | sum.Kind, sum.State = "bad", "checks failed" | |
| 703 | + | default: | |
| 704 | + | sum.Kind, sum.State = "running", "checks pending" | |
| 705 | + | } | |
| 706 | + | sum.Title = sum.State + ": " + sum.Label | |
| 707 | + | return sum | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | // checksSection is the checks of the rail of a merge request, with the rule of | |
| 711 | + | // the repository that applies to them. | |
| 712 | + | func checksSection(repo domain.Repo, checks []ports.CheckView, now time.Time) ChecksSection { | |
| 713 | + | sec := ChecksSection{} | |
| 714 | + | for _, c := range checks { | |
| 715 | + | row := CheckRow{ | |
| 716 | + | Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion), | |
| 717 | + | Waiting: c.WaitingForRunner, | |
| 718 | + | } | |
| 719 | + | if c.JobID != "" { | |
| 720 | + | row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID) | |
| 721 | + | } else { | |
| 722 | + | row.Href = runHref(repo.Name.String(), c.RunID) | |
| 723 | + | } | |
| 724 | + | sec.Rows = append(sec.Rows, row) | |
| 725 | + | } | |
| 726 | + | switch { | |
| 727 | + | case len(checks) == 0: | |
| 728 | + | sec.Note = "no check on this commit: nothing blocks the merge." | |
| 729 | + | case repo.Settings.FailingChecksBlock: | |
| 730 | + | sec.Note = "a failing or unfinished check blocks the merge." | |
| 731 | + | default: | |
| 732 | + | sec.Note = "checks do not block the merge in this repository." | |
| 733 | + | } | |
| 734 | + | _ = now | |
| 735 | + | return sec | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | // logStream is GET /<repo>/actions/runs/<id>/job/<job id>/stream?from=N: the | |
| 739 | + | // lines of a job log from line N as Server-Sent Events, following the job while | |
| 740 | + | // it runs. Each "ci-log" event holds the <li> of the lines of one batch and has | |
| 741 | + | // the number of the next line as its id, so that a reconnection (Last-Event-ID) | |
| 742 | + | // goes on where it stopped. "ci-end" tells the job is over and every line was | |
| 743 | + | // sent. The job events of the repository stream carry no line (see events.go). | |
| 744 | + | func (s *Server) logStream(w http.ResponseWriter, r *http.Request) { | |
| 745 | + | ctx, cancel := context.WithCancel(r.Context()) | |
| 746 | + | defer cancel() | |
| 747 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 748 | + | if err != nil { | |
| 749 | + | s.denyEvents(ctx, w, err) | |
| 750 | + | return | |
| 751 | + | } | |
| 752 | + | id, err := parseRunPath(r) | |
| 753 | + | jobID := r.PathValue("job") | |
| 754 | + | if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id { | |
| 755 | + | s.denyEvents(ctx, w, errNotFound) | |
| 756 | + | return | |
| 757 | + | } | |
| 758 | + | from := int64(0) | |
| 759 | + | if v := r.Header.Get("Last-Event-ID"); v != "" { | |
| 760 | + | from, err = strconv.ParseInt(v, 10, 64) | |
| 761 | + | } else if v := r.URL.Query().Get("from"); v != "" { | |
| 762 | + | from, err = strconv.ParseInt(v, 10, 64) | |
| 763 | + | } | |
| 764 | + | if err != nil || from < 0 { | |
| 765 | + | s.denyEvents(ctx, w, errNotFound) | |
| 766 | + | return | |
| 767 | + | } | |
| 768 | + | view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0) | |
| 769 | + | if err != nil { | |
| 770 | + | s.denyEvents(ctx, w, err) | |
| 771 | + | return | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | rc := http.NewResponseController(w) | |
| 775 | + | hdr := w.Header() | |
| 776 | + | hdr.Set("Content-Type", "text/event-stream") | |
| 777 | + | cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform") | |
| 778 | + | hdr.Set("X-Accel-Buffering", "no") | |
| 779 | + | w.WriteHeader(http.StatusOK) | |
| 780 | + | out := &sseWriter{w: w, rc: rc} | |
| 781 | + | if out.raw(": connected\n\n") != nil { | |
| 782 | + | return | |
| 783 | + | } | |
| 784 | + | ranges := view.StepRanges() | |
| 785 | + | _ = ranges | |
| 786 | + | n := from | |
| 787 | + | err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{ | |
| 788 | + | JobID: jobID, Offset: from, Follow: true, | |
| 789 | + | Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)), | |
| 790 | + | }, func(b ports.LogBatch) error { | |
| 791 | + | var html strings.Builder | |
| 792 | + | for i, l := range b.Lines { | |
| 793 | + | v := LogLineView{N: b.Offset + int64(i), Time: l.Time.UTC().Format("15:04:05"), Step: l.Step} | |
| 794 | + | text := l.Text | |
| 795 | + | switch { | |
| 796 | + | case strings.HasPrefix(text, "##[group]"): | |
| 797 | + | v.Group, text = true, strings.TrimPrefix(text, "##[group]") | |
| 798 | + | case strings.HasPrefix(text, "##[endgroup]"): | |
| 799 | + | v.End, text = true, strings.TrimPrefix(text, "##[endgroup]") | |
| 800 | + | case strings.HasPrefix(text, "##[error]"): | |
| 801 | + | v.Error, text = true, strings.TrimPrefix(text, "##[error]") | |
| 802 | + | } | |
| 803 | + | v.Text = visibleText(text) | |
| 804 | + | if l.Continued { | |
| 805 | + | continue // joined to its line by the page reload; rare and harmless | |
| 806 | + | } | |
| 807 | + | var b2 strings.Builder | |
| 808 | + | if err := LogLine(v).Render(ctx, &b2); err != nil { | |
| 809 | + | return err | |
| 810 | + | } | |
| 811 | + | html.WriteString(strings.ReplaceAll(b2.String(), "\n", "")) | |
| 812 | + | } | |
| 813 | + | n = b.Offset + int64(len(b.Lines)) | |
| 814 | + | _ = rc.SetWriteDeadline(time.Now().Add(writeWindow)) | |
| 815 | + | _, werr := io.WriteString(w, "event: ci-log\nid: "+strconv.FormatInt(n, 10)+"\ndata: "+html.String()+"\n\n") | |
| 816 | + | if werr != nil { | |
| 817 | + | return werr | |
| 818 | + | } | |
| 819 | + | return rc.Flush() | |
| 820 | + | }) | |
| 821 | + | if err == nil { | |
| 822 | + | _ = out.raw("event: ci-end\nid: " + strconv.FormatInt(n, 10) + "\ndata: end\n\n") | |
| 823 | + | } | |
| 824 | + | } | |
| @@ -0,0 +1,824 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "net/http" | |||
| 11 | + | "strconv" | |||
| 12 | + | "strings" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/a-h/templ" | |||
| 16 | + | "github.com/bnema/zerowrap" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite/internal/clientlimit" | |||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // The CI pages (ADR 0025): the runs of a repository, one run with its jobs, | |||
| 24 | + | // one job with its steps and its log. They read through ports.RunService and | |||
| 25 | + | // follow the events RunUpdated and JobUpdated of the repository topic. | |||
| 26 | + | // | |||
| 27 | + | // GET /<repo>/actions the runs, newest first | |||
| 28 | + | // GET /<repo>/actions/runs/<id> one run: jobs and steps | |||
| 29 | + | // GET /<repo>/actions/runs/<id>/job/<job id> one job: steps and live log | |||
| 30 | + | // GET /<repo>/actions/runs/<id>/job/<job id>/raw the log as text | |||
| 31 | + | // POST /<repo>/actions/runs/<id>/cancel | rerun | rerun-failed (the owner) | |||
| 32 | + | ||||
| 33 | + | const ( | |||
| 34 | + | // actionsPageSize is the number of runs one page lists. | |||
| 35 | + | actionsPageSize = 30 | |||
| 36 | + | // jobLogPageLines is the number of log lines a job page shows at once; the | |||
| 37 | + | // rest is reached with ?from=. | |||
| 38 | + | jobLogPageLines = 2000 | |||
| 39 | + | // rawLogMaxBytes bounds the raw log the server writes to one response. | |||
| 40 | + | rawLogMaxBytes = 64 << 20 | |||
| 41 | + | ) | |||
| 42 | + | ||||
| 43 | + | func actionsBase(repo string) string { return repoHref(repo, "actions") } | |||
| 44 | + | func runHref(repo string, id int64) string { | |||
| 45 | + | return repoHref(repo, "actions", "runs", strconv.FormatInt(id, 10)) | |||
| 46 | + | } | |||
| 47 | + | func jobHref(repo string, runID int64, jobID string) string { | |||
| 48 | + | return runHref(repo, runID) + "/job/" + jobID | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // ciState is the one word shown for a status and a conclusion: what is going | |||
| 52 | + | // on, or how it ended. | |||
| 53 | + | func ciState(status domain.CIStatus, c domain.CIConclusion) string { | |||
| 54 | + | switch status { | |||
| 55 | + | case domain.CIQueued: | |||
| 56 | + | return "queued" | |||
| 57 | + | case domain.CIInProgress: | |||
| 58 | + | return "running" | |||
| 59 | + | } | |||
| 60 | + | switch c { | |||
| 61 | + | case domain.CISuccess: | |||
| 62 | + | return "success" | |||
| 63 | + | case domain.CIFailure: | |||
| 64 | + | return "failure" | |||
| 65 | + | case domain.CICancelled: | |||
| 66 | + | return "cancelled" | |||
| 67 | + | case domain.CISkipped: | |||
| 68 | + | return "skipped" | |||
| 69 | + | case domain.CITimedOut: | |||
| 70 | + | return "timed out" | |||
| 71 | + | case domain.CINeutral: | |||
| 72 | + | return "neutral" | |||
| 73 | + | case domain.CIActionRequired: | |||
| 74 | + | return "action required" | |||
| 75 | + | } | |||
| 76 | + | return "done" | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // ciKind groups the states into the few that have an icon and a colour. | |||
| 80 | + | func ciKind(status domain.CIStatus, c domain.CIConclusion) string { | |||
| 81 | + | switch { | |||
| 82 | + | case status == domain.CIQueued: | |||
| 83 | + | return "queued" | |||
| 84 | + | case status == domain.CIInProgress: | |||
| 85 | + | return "running" | |||
| 86 | + | case c == domain.CISuccess, c == domain.CINeutral: | |||
| 87 | + | return "ok" | |||
| 88 | + | case c == domain.CISkipped: | |||
| 89 | + | return "skipped" | |||
| 90 | + | case c == domain.CICancelled: | |||
| 91 | + | return "cancelled" | |||
| 92 | + | } | |||
| 93 | + | return "bad" | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // duration is a short elapsed time between two instants; empty when the | |||
| 97 | + | // start is unknown, running up to now when the end is not. | |||
| 98 | + | func duration(start, end, now time.Time) string { | |||
| 99 | + | if start.IsZero() { | |||
| 100 | + | return "" | |||
| 101 | + | } | |||
| 102 | + | if end.IsZero() { | |||
| 103 | + | end = now | |||
| 104 | + | } | |||
| 105 | + | d := end.Sub(start).Round(time.Second) | |||
| 106 | + | switch { | |||
| 107 | + | case d < 0: | |||
| 108 | + | return "" | |||
| 109 | + | case d < time.Minute: | |||
| 110 | + | return fmt.Sprintf("%ds", int(d.Seconds())) | |||
| 111 | + | case d < time.Hour: | |||
| 112 | + | return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60) | |||
| 113 | + | } | |||
| 114 | + | return fmt.Sprintf("%dh %02dm", int(d.Hours()), int(d.Minutes())%60) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // RunRow is a run of the list. | |||
| 118 | + | type RunRow struct { | |||
| 119 | + | ID string | |||
| 120 | + | Href string | |||
| 121 | + | Kind string // ciKind | |||
| 122 | + | State string // ciState | |||
| 123 | + | Workflow string | |||
| 124 | + | Title string | |||
| 125 | + | Ref string | |||
| 126 | + | RefHref string | |||
| 127 | + | Short string | |||
| 128 | + | CHref string | |||
| 129 | + | Actor string | |||
| 130 | + | When string | |||
| 131 | + | WhenAt string | |||
| 132 | + | Duration string | |||
| 133 | + | Number string | |||
| 134 | + | Attempt int32 | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // ActionsPage lists the runs of a repository. | |||
| 138 | + | type ActionsPage struct { | |||
| 139 | + | Runs []RunRow | |||
| 140 | + | // More is the link to the next page, "" on the last. | |||
| 141 | + | More string | |||
| 142 | + | // NoRunner says that CI has no runner to run on yet. | |||
| 143 | + | NoRunner bool | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // StepView is a step of a job. | |||
| 147 | + | type StepView struct { | |||
| 148 | + | Number int32 | |||
| 149 | + | Name string | |||
| 150 | + | Kind, State string | |||
| 151 | + | Duration string | |||
| 152 | + | // LogHref jumps to the lines of the step in the job log. | |||
| 153 | + | LogStart, LogEnd int64 | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // JobRow is a job of a run. | |||
| 157 | + | type JobRow struct { | |||
| 158 | + | ID, Name, Href string | |||
| 159 | + | Kind, State string | |||
| 160 | + | Runner string | |||
| 161 | + | Isolation string | |||
| 162 | + | Duration string | |||
| 163 | + | Error string | |||
| 164 | + | Needs []string | |||
| 165 | + | Steps []StepView | |||
| 166 | + | Attempt int32 | |||
| 167 | + | // Waiting says that the job is queued for a runner that no registered | |||
| 168 | + | // runner can be. | |||
| 169 | + | Waiting bool | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // RunPage is one run with its jobs. | |||
| 173 | + | type RunPage struct { | |||
| 174 | + | RunRow | |||
| 175 | + | Sha, Error string | |||
| 176 | + | Attempt int32 | |||
| 177 | + | Latest int32 | |||
| 178 | + | Attempts []AttemptLink | |||
| 179 | + | Jobs []JobRow | |||
| 180 | + | Active bool | |||
| 181 | + | // Forms are the owner's actions; nil/empty when the visitor cannot. | |||
| 182 | + | CancelAction, RerunAction, RerunFailedAction string | |||
| 183 | + | CanRerunFailed bool | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // AttemptLink switches between the attempts of a run. | |||
| 187 | + | type AttemptLink struct { | |||
| 188 | + | Label, Href string | |||
| 189 | + | Current bool | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // LogLineView is a line of a job log, as shown. | |||
| 193 | + | type LogLineView struct { | |||
| 194 | + | N int64 | |||
| 195 | + | Step int32 | |||
| 196 | + | Text string | |||
| 197 | + | Time string | |||
| 198 | + | // Group marks "##[group]" and "##[endgroup]" lines and Error the | |||
| 199 | + | // "##[error]" ones; the text is shown without the marker. | |||
| 200 | + | Group, End, Error bool | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // JobPage is one job with its steps and its log. | |||
| 204 | + | type JobPage struct { | |||
| 205 | + | JobRow | |||
| 206 | + | RunHref string | |||
| 207 | + | RunName string | |||
| 208 | + | Run string | |||
| 209 | + | Steps []StepView | |||
| 210 | + | Lines []LogLineView | |||
| 211 | + | // From is the first line shown; Next is the link to the lines after the | |||
| 212 | + | // last shown, "" when it is the end; Follow says the log is still being | |||
| 213 | + | // written, so the page follows it live. | |||
| 214 | + | From int64 | |||
| 215 | + | Next string | |||
| 216 | + | Prev string | |||
| 217 | + | Total int64 | |||
| 218 | + | Follow bool | |||
| 219 | + | RawHref string | |||
| 220 | + | // StreamHref is the live feed of the lines (logStream). | |||
| 221 | + | StreamHref string | |||
| 222 | + | Note string | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // --- handlers --- | |||
| 226 | + | ||||
| 227 | + | // actions is GET /<repo>/actions. | |||
| 228 | + | func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |||
| 229 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |||
| 230 | + | if err != nil { | |||
| 231 | + | return nil, 0, err | |||
| 232 | + | } | |||
| 233 | + | token := r.URL.Query().Get("page") | |||
| 234 | + | page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), d.repo.Name, ports.ListRunsParams{PageSize: actionsPageSize, PageToken: token}) | |||
| 235 | + | if err != nil { | |||
| 236 | + | return nil, 0, err | |||
| 237 | + | } | |||
| 238 | + | now := s.cfg.Now() | |||
| 239 | + | p := ActionsPage{} | |||
| 240 | + | for _, run := range page.Runs { | |||
| 241 | + | p.Runs = append(p.Runs, s.runRow(d.name(), run, now)) | |||
| 242 | + | } | |||
| 243 | + | if page.NextPageToken != "" { | |||
| 244 | + | p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken | |||
| 245 | + | } | |||
| 246 | + | l := s.repoLayout(ctx, d, "actions") | |||
| 247 | + | l.Title = "actions · " + d.name() + " · " + s.instance | |||
| 248 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions") | |||
| 249 | + | l.Crumbs[0].Current = false | |||
| 250 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Current: true}) | |||
| 251 | + | return Actions(l, p), http.StatusOK, nil | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow { | |||
| 255 | + | row := RunRow{ | |||
| 256 | + | ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID), | |||
| 257 | + | Kind: ciKind(run.Status, run.Conclusion), State: ciState(run.Status, run.Conclusion), | |||
| 258 | + | Workflow: visibleText(run.WorkflowName), Title: visibleText(run.Title), | |||
| 259 | + | Ref: visibleText(strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")), | |||
| 260 | + | Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: visibleText(run.Actor), | |||
| 261 | + | Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt, | |||
| 262 | + | } | |||
| 263 | + | row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")) | |||
| 264 | + | row.When, row.WhenAt = whenOf(now, run.CreatedAt) | |||
| 265 | + | return row | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func stepViews(j ports.JobView, now time.Time) []StepView { | |||
| 269 | + | ranges := j.StepRanges() | |||
| 270 | + | out := make([]StepView, 0, len(j.Steps)) | |||
| 271 | + | for i, st := range j.Steps { | |||
| 272 | + | status := domain.CIQueued | |||
| 273 | + | switch st.State { | |||
| 274 | + | case domain.CIStepInProgress: | |||
| 275 | + | status = domain.CIInProgress | |||
| 276 | + | case domain.CIStepCompleted: | |||
| 277 | + | status = domain.CICompleted | |||
| 278 | + | } | |||
| 279 | + | out = append(out, StepView{ | |||
| 280 | + | Number: st.Number, Name: visibleText(st.Name), Kind: ciKind(status, st.Conclusion), State: ciState(status, st.Conclusion), | |||
| 281 | + | Duration: duration(st.StartedAt, st.CompletedAt, now), LogStart: ranges[i].Start, LogEnd: ranges[i].End, | |||
| 282 | + | }) | |||
| 283 | + | } | |||
| 284 | + | return out | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func (s *Server) jobRow(repo string, j ports.JobView, now time.Time) JobRow { | |||
| 288 | + | return JobRow{ | |||
| 289 | + | ID: j.ID, Name: visibleText(j.Name), Href: jobHref(repo, j.RunID, j.ID), Kind: ciKind(j.Status(), j.Conclusion), | |||
| 290 | + | State: ciState(j.Status(), j.Conclusion), Runner: visibleText(j.RunnerName), Isolation: string(j.Isolation), | |||
| 291 | + | Duration: duration(j.StartedAt, j.CompletedAt, now), Error: visibleText(j.Error), Needs: j.Needs, | |||
| 292 | + | Steps: stepViews(j, now), Attempt: j.Attempt, | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func parseRunPath(r *http.Request) (int64, error) { | |||
| 297 | + | id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) | |||
| 298 | + | if err != nil || id < 1 { | |||
| 299 | + | return 0, errNotFound | |||
| 300 | + | } | |||
| 301 | + | return id, nil | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | // run is GET /<repo>/actions/runs/<id>. | |||
| 305 | + | func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |||
| 306 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |||
| 307 | + | if err != nil { | |||
| 308 | + | return nil, 0, err | |||
| 309 | + | } | |||
| 310 | + | id, err := parseRunPath(r) | |||
| 311 | + | if err != nil { | |||
| 312 | + | return nil, 0, err | |||
| 313 | + | } | |||
| 314 | + | var attempt int32 | |||
| 315 | + | if a := r.URL.Query().Get("attempt"); a != "" { | |||
| 316 | + | n, perr := strconv.ParseInt(a, 10, 32) | |||
| 317 | + | if perr != nil || n < 1 { | |||
| 318 | + | return nil, 0, errNotFound | |||
| 319 | + | } | |||
| 320 | + | attempt = int32(n) | |||
| 321 | + | } | |||
| 322 | + | p, err := s.runPage(ctx, d, id, attempt) | |||
| 323 | + | if err != nil { | |||
| 324 | + | return nil, 0, err | |||
| 325 | + | } | |||
| 326 | + | l := s.repoLayout(ctx, d, "actions") | |||
| 327 | + | l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance | |||
| 328 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID) | |||
| 329 | + | l.Crumbs[0].Current = false | |||
| 330 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Href: actionsBase(d.name())}, Crumb{Label: p.Workflow + " " + p.Number, Current: true}) | |||
| 331 | + | l.HasRail = true | |||
| 332 | + | return RunDetail(l, p), http.StatusOK, nil | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the run page from the use case results; no rule is decided here | |||
| 336 | + | func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int32) (RunPage, error) { | |||
| 337 | + | detail, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, attempt) | |||
| 338 | + | if err != nil { | |||
| 339 | + | return RunPage{}, err | |||
| 340 | + | } | |||
| 341 | + | now := s.cfg.Now() | |||
| 342 | + | run := detail.Run | |||
| 343 | + | p := RunPage{ | |||
| 344 | + | RunRow: s.runRow(d.name(), run, now), Sha: run.Sha, Error: visibleText(run.Error), | |||
| 345 | + | Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted, | |||
| 346 | + | } | |||
| 347 | + | for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- { | |||
| 348 | + | p.Attempts = append(p.Attempts, AttemptLink{ | |||
| 349 | + | Label: "attempt " + strconv.Itoa(int(a)), Href: runHref(d.name(), id) + "?attempt=" + strconv.Itoa(int(a)), Current: a == run.Attempt, | |||
| 350 | + | }) | |||
| 351 | + | } | |||
| 352 | + | for _, j := range detail.Jobs { | |||
| 353 | + | row := s.jobRow(d.name(), j, now) | |||
| 354 | + | if run.Attempt == detail.LatestAttempt { | |||
| 355 | + | // An earlier attempt keeps the job as it ran then. | |||
| 356 | + | row.Href = jobHref(d.name(), id, j.ID) | |||
| 357 | + | } | |||
| 358 | + | p.Jobs = append(p.Jobs, row) | |||
| 359 | + | } | |||
| 360 | + | if SignedIn(ctx) && run.Attempt == detail.LatestAttempt { | |||
| 361 | + | base := runHref(d.name(), id) | |||
| 362 | + | if p.Active { | |||
| 363 | + | p.CancelAction = base + "/cancel" | |||
| 364 | + | } else if len(detail.Jobs) > 0 { | |||
| 365 | + | p.RerunAction = base + "/rerun" | |||
| 366 | + | for _, j := range detail.Jobs { | |||
| 367 | + | if j.Conclusion.IsFailure() || j.Conclusion == domain.CICancelled { | |||
| 368 | + | p.RerunFailedAction, p.CanRerunFailed = base+"/rerun-failed", true | |||
| 369 | + | break | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | } | |||
| 374 | + | return p, nil | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | // job is GET /<repo>/actions/runs/<id>/job/<job id>. | |||
| 378 | + | func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |||
| 379 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |||
| 380 | + | if err != nil { | |||
| 381 | + | return nil, 0, err | |||
| 382 | + | } | |||
| 383 | + | id, err := parseRunPath(r) | |||
| 384 | + | if err != nil { | |||
| 385 | + | return nil, 0, err | |||
| 386 | + | } | |||
| 387 | + | jobID := r.PathValue("job") | |||
| 388 | + | if runOf, _, _, ok := domain.ParseJobID(jobID); !ok || runOf != id { | |||
| 389 | + | return nil, 0, errNotFound | |||
| 390 | + | } | |||
| 391 | + | var attempt int32 | |||
| 392 | + | if a := r.URL.Query().Get("attempt"); a != "" { | |||
| 393 | + | n, perr := strconv.ParseInt(a, 10, 32) | |||
| 394 | + | if perr != nil || n < 1 { | |||
| 395 | + | return nil, 0, errNotFound | |||
| 396 | + | } | |||
| 397 | + | attempt = int32(n) | |||
| 398 | + | } | |||
| 399 | + | view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt) | |||
| 400 | + | if err != nil { | |||
| 401 | + | return nil, 0, err | |||
| 402 | + | } | |||
| 403 | + | run, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, 0) | |||
| 404 | + | if err != nil { | |||
| 405 | + | return nil, 0, err | |||
| 406 | + | } | |||
| 407 | + | now := s.cfg.Now() | |||
| 408 | + | var from int64 | |||
| 409 | + | if f := r.URL.Query().Get("from"); f != "" { | |||
| 410 | + | from, err = strconv.ParseInt(f, 10, 64) | |||
| 411 | + | if err != nil || from < 0 { | |||
| 412 | + | return nil, 0, errNotFound | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | p := JobPage{ | |||
| 416 | + | JobRow: s.jobRow(d.name(), view, now), RunHref: runHref(d.name(), id), RunName: visibleText(run.Run.WorkflowName), | |||
| 417 | + | Run: strconv.FormatInt(id, 10), Steps: stepViews(view, now), From: from, Total: view.Lines, | |||
| 418 | + | RawHref: jobHref(d.name(), id, jobID) + "/raw", | |||
| 419 | + | } | |||
| 420 | + | if attempt > 0 { | |||
| 421 | + | p.RawHref += "?attempt=" + strconv.Itoa(int(attempt)) | |||
| 422 | + | } | |||
| 423 | + | p.Follow = !view.Ended() | |||
| 424 | + | p.StreamHref = jobHref(d.name(), id, jobID) + "/stream" | |||
| 425 | + | lines, next, err := s.readLog(ctx, d.repo.Name, view, from, jobLogPageLines) | |||
| 426 | + | if err != nil { | |||
| 427 | + | return nil, 0, err | |||
| 428 | + | } | |||
| 429 | + | p.Lines = lines | |||
| 430 | + | if next < view.Lines { | |||
| 431 | + | p.Next = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(next, 10) | |||
| 432 | + | p.Follow = false // more is waiting before the live end | |||
| 433 | + | } | |||
| 434 | + | if from > 0 { | |||
| 435 | + | p.Prev = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(max(from-jobLogPageLines, 0), 10) | |||
| 436 | + | } | |||
| 437 | + | if view.Phase == domain.CIPhaseWaiting || view.Phase == domain.CIPhaseReady { | |||
| 438 | + | p.Note = "the job has not started: no log yet." | |||
| 439 | + | } else if view.Lines == 0 && view.Ended() { | |||
| 440 | + | p.Note = "the job wrote no log." | |||
| 441 | + | } | |||
| 442 | + | l := s.repoLayout(ctx, d, "actions") | |||
| 443 | + | l.Title = view.Name + " · run " + p.Run + " · " + d.name() + " · " + s.instance | |||
| 444 | + | l.Live = true | |||
| 445 | + | l.LiveURL = liveURL("repo:"+d.name(), "view=job&job="+jobID) | |||
| 446 | + | l.Crumbs[0].Current = false | |||
| 447 | + | l.Crumbs = append(l.Crumbs, | |||
| 448 | + | Crumb{Label: "actions", Href: actionsBase(d.name())}, | |||
| 449 | + | Crumb{Label: p.RunName + " #" + strconv.FormatInt(run.Run.Number, 10), Href: p.RunHref}, | |||
| 450 | + | Crumb{Label: p.Name, Current: true}) | |||
| 451 | + | l.HasRail = true | |||
| 452 | + | return Job(l, p), http.StatusOK, nil | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | // readLog reads up to max lines from a line number, through the log stream of | |||
| 456 | + | // the use case, and returns the line after the last read. | |||
| 457 | + | func (s *Server) readLog(ctx context.Context, repo domain.RepoName, j ports.JobView, from int64, max int) ([]LogLineView, int64, error) { | |||
| 458 | + | if from > j.Lines || j.Lines == 0 { | |||
| 459 | + | return nil, from, nil | |||
| 460 | + | } | |||
| 461 | + | var out []LogLineView | |||
| 462 | + | next := from | |||
| 463 | + | ranges := j.StepRanges() | |||
| 464 | + | stepOf := func(n int64) int32 { | |||
| 465 | + | for i, r := range ranges { | |||
| 466 | + | if n >= r.Start && n < r.End { | |||
| 467 | + | return j.Steps[i].Number | |||
| 468 | + | } | |||
| 469 | + | } | |||
| 470 | + | return 0 | |||
| 471 | + | } | |||
| 472 | + | stop := errors.New("enough") | |||
| 473 | + | err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from}, func(b ports.LogBatch) error { | |||
| 474 | + | for i, l := range b.Lines { | |||
| 475 | + | if len(out) >= max { | |||
| 476 | + | return stop | |||
| 477 | + | } | |||
| 478 | + | n := b.Offset + int64(i) | |||
| 479 | + | text := l.Text | |||
| 480 | + | v := LogLineView{N: n, Step: stepOf(n), Time: l.Time.UTC().Format("15:04:05")} | |||
| 481 | + | switch { | |||
| 482 | + | case strings.HasPrefix(text, "##[group]"): | |||
| 483 | + | v.Group, text = true, strings.TrimPrefix(text, "##[group]") | |||
| 484 | + | case strings.HasPrefix(text, "##[endgroup]"): | |||
| 485 | + | v.End, text = true, strings.TrimPrefix(text, "##[endgroup]") | |||
| 486 | + | case strings.HasPrefix(text, "##[error]"): | |||
| 487 | + | v.Error, text = true, strings.TrimPrefix(text, "##[error]") | |||
| 488 | + | } | |||
| 489 | + | if l.Continued && len(out) > 0 { | |||
| 490 | + | out[len(out)-1].Text += visibleText(text) | |||
| 491 | + | next = n + 1 | |||
| 492 | + | continue | |||
| 493 | + | } | |||
| 494 | + | v.Text = visibleText(text) | |||
| 495 | + | out = append(out, v) | |||
| 496 | + | next = n + 1 | |||
| 497 | + | } | |||
| 498 | + | return nil | |||
| 499 | + | }) | |||
| 500 | + | if err != nil && !errors.Is(err, stop) && !errors.Is(err, domain.ErrNotFound) { | |||
| 501 | + | return nil, from, err | |||
| 502 | + | } | |||
| 503 | + | return out, next, nil | |||
| 504 | + | } | |||
| 505 | + | ||||
| 506 | + | // rawLog is GET /<repo>/actions/runs/<id>/job/<job id>/raw: the log as text, | |||
| 507 | + | // one line per line with its time, joined where the runner cut a long line. | |||
| 508 | + | func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) { | |||
| 509 | + | ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) | |||
| 510 | + | defer cancel() | |||
| 511 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |||
| 512 | + | if err != nil { | |||
| 513 | + | s.fail(ctx, w, r, err) | |||
| 514 | + | return | |||
| 515 | + | } | |||
| 516 | + | id, err := parseRunPath(r) | |||
| 517 | + | jobID := r.PathValue("job") | |||
| 518 | + | if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id { | |||
| 519 | + | s.fail(ctx, w, r, errNotFound) | |||
| 520 | + | return | |||
| 521 | + | } | |||
| 522 | + | var attempt int32 | |||
| 523 | + | if a := r.URL.Query().Get("attempt"); a != "" { | |||
| 524 | + | n, perr := strconv.ParseInt(a, 10, 32) | |||
| 525 | + | if perr != nil || n < 1 { | |||
| 526 | + | s.fail(ctx, w, r, errNotFound) | |||
| 527 | + | return | |||
| 528 | + | } | |||
| 529 | + | attempt = int32(n) | |||
| 530 | + | } | |||
| 531 | + | view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt) | |||
| 532 | + | if err != nil { | |||
| 533 | + | s.fail(ctx, w, r, err) | |||
| 534 | + | return | |||
| 535 | + | } | |||
| 536 | + | h := w.Header() | |||
| 537 | + | h.Set("Content-Type", "text/plain; charset=utf-8") | |||
| 538 | + | h.Set("X-Content-Type-Options", "nosniff") | |||
| 539 | + | h.Set("Content-Security-Policy", "default-src 'none'; sandbox") | |||
| 540 | + | h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="job-%s.log"`, jobID)) | |||
| 541 | + | cacheHeaders(ctx, h, "no-cache", sessionCache) | |||
| 542 | + | written := 0 | |||
| 543 | + | open := false | |||
| 544 | + | err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{JobID: jobID, Attempt: attempt}, func(b ports.LogBatch) error { | |||
| 545 | + | for _, l := range b.Lines { | |||
| 546 | + | if written > rawLogMaxBytes { | |||
| 547 | + | return errors.New("log too large") | |||
| 548 | + | } | |||
| 549 | + | var line string | |||
| 550 | + | switch { | |||
| 551 | + | case l.Continued && open: | |||
| 552 | + | line = l.Text | |||
| 553 | + | case open: | |||
| 554 | + | line = "\n" + l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text | |||
| 555 | + | default: | |||
| 556 | + | line = l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text | |||
| 557 | + | } | |||
| 558 | + | open = true | |||
| 559 | + | n, werr := fmt.Fprint(w, line) | |||
| 560 | + | written += n | |||
| 561 | + | if werr != nil { | |||
| 562 | + | return werr | |||
| 563 | + | } | |||
| 564 | + | } | |||
| 565 | + | return nil | |||
| 566 | + | }) | |||
| 567 | + | if open { | |||
| 568 | + | _, _ = fmt.Fprintln(w) | |||
| 569 | + | } | |||
| 570 | + | if err != nil && !open && errors.Is(err, domain.ErrNotFound) { | |||
| 571 | + | _ = view | |||
| 572 | + | w.WriteHeader(http.StatusNotFound) | |||
| 573 | + | } | |||
| 574 | + | } | |||
| 575 | + | ||||
| 576 | + | // --- the owner's actions --- | |||
| 577 | + | ||||
| 578 | + | // ciAction wraps a POST of the actions pages: the form gate, the bounded | |||
| 579 | + | // context and the mapping of a refusal to a page. | |||
| 580 | + | func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc { | |||
| 581 | + | return s.mrAction(func(ctx context.Context, r *http.Request) (string, string, error) { | |||
| 582 | + | to, err := h(ctx, r) | |||
| 583 | + | return to, actionsBase(r.PathValue("repo")), err | |||
| 584 | + | }) | |||
| 585 | + | } | |||
| 586 | + | ||||
| 587 | + | func (s *Server) runCancel(ctx context.Context, r *http.Request) (string, error) { | |||
| 588 | + | name, id, err := s.runTarget(r) | |||
| 589 | + | if err != nil { | |||
| 590 | + | return "", err | |||
| 591 | + | } | |||
| 592 | + | if _, err := s.cfg.Runs.CancelRun(ctx, principal(ctx), name, id); err != nil { | |||
| 593 | + | return "", err | |||
| 594 | + | } | |||
| 595 | + | return runHref(name.String(), id), nil | |||
| 596 | + | } | |||
| 597 | + | ||||
| 598 | + | func (s *Server) runRerun(failedOnly bool) func(ctx context.Context, r *http.Request) (string, error) { | |||
| 599 | + | return func(ctx context.Context, r *http.Request) (string, error) { | |||
| 600 | + | name, id, err := s.runTarget(r) | |||
| 601 | + | if err != nil { | |||
| 602 | + | return "", err | |||
| 603 | + | } | |||
| 604 | + | if _, err := s.cfg.Runs.RerunRun(ctx, principal(ctx), name, id, failedOnly); err != nil { | |||
| 605 | + | return "", err | |||
| 606 | + | } | |||
| 607 | + | return runHref(name.String(), id), nil | |||
| 608 | + | } | |||
| 609 | + | } | |||
| 610 | + | ||||
| 611 | + | func (s *Server) runTarget(r *http.Request) (domain.RepoName, int64, error) { | |||
| 612 | + | name, err := domain.ParseRepoName(r.PathValue("repo")) | |||
| 613 | + | if err != nil { | |||
| 614 | + | return domain.RepoName{}, 0, errNotFound | |||
| 615 | + | } | |||
| 616 | + | id, err := parseRunPath(r) | |||
| 617 | + | return name, id, err | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | func itoa64(n int64) string { return strconv.FormatInt(n, 10) } | |||
| 621 | + | ||||
| 622 | + | // runnerText names the runner of a job and how it isolated it. | |||
| 623 | + | func runnerText(j JobRow) string { | |||
| 624 | + | if j.Isolation == "" { | |||
| 625 | + | return j.Runner | |||
| 626 | + | } | |||
| 627 | + | return j.Runner + " (" + j.Isolation + ")" | |||
| 628 | + | } | |||
| 629 | + | ||||
| 630 | + | func joinWords(ws []string) string { return strings.Join(ws, ", ") } | |||
| 631 | + | ||||
| 632 | + | // logClass is the class of a line of the log. | |||
| 633 | + | func logClass(ln LogLineView) string { | |||
| 634 | + | switch { | |||
| 635 | + | case ln.Group: | |||
| 636 | + | return "lg group" | |||
| 637 | + | case ln.End: | |||
| 638 | + | return "lg endgroup" | |||
| 639 | + | case ln.Error: | |||
| 640 | + | return "lg error" | |||
| 641 | + | } | |||
| 642 | + | return "lg" | |||
| 643 | + | } | |||
| 644 | + | ||||
| 645 | + | // ChecksSummary is the combined state of the checks of a commit. | |||
| 646 | + | type ChecksSummary struct { | |||
| 647 | + | Total int | |||
| 648 | + | Kind, State string | |||
| 649 | + | Label, Title string | |||
| 650 | + | Href string | |||
| 651 | + | } | |||
| 652 | + | ||||
| 653 | + | // CheckRow is a check of a commit or a merge request. | |||
| 654 | + | type CheckRow struct { | |||
| 655 | + | Name, Kind, State, Href string | |||
| 656 | + | Waiting bool | |||
| 657 | + | } | |||
| 658 | + | ||||
| 659 | + | // ChecksSection is the checks of the head of a merge request. | |||
| 660 | + | type ChecksSection struct { | |||
| 661 | + | Rows []CheckRow | |||
| 662 | + | // Note explains the rule that applies to them. | |||
| 663 | + | Note string | |||
| 664 | + | } | |||
| 665 | + | ||||
| 666 | + | // checksFor reads the checks of a commit as the caller of the request. A page | |||
| 667 | + | // shows them when it can: a failure of the read is logged and the page goes on | |||
| 668 | + | // without, since the code and the review do not depend on CI. | |||
| 669 | + | func (s *Server) checksFor(ctx context.Context, repo domain.RepoName, sha string) []ports.CheckView { | |||
| 670 | + | if s.cfg.Runs == nil || sha == "" { | |||
| 671 | + | return nil | |||
| 672 | + | } | |||
| 673 | + | res, err := s.cfg.Runs.ListChecks(ctx, principal(ctx), repo, sha) | |||
| 674 | + | if err != nil { | |||
| 675 | + | if !errors.Is(err, domain.ErrNotFound) { | |||
| 676 | + | log := zerowrap.FromCtx(ctx) | |||
| 677 | + | log.Warn().Err(err).Str("repo", repo.String()).Msg("read the checks of a commit") | |||
| 678 | + | } | |||
| 679 | + | return nil | |||
| 680 | + | } | |||
| 681 | + | return res.Checks | |||
| 682 | + | } | |||
| 683 | + | ||||
| 684 | + | // checksSummary reduces checks to the mark a list shows next to a commit. | |||
| 685 | + | func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary { | |||
| 686 | + | if len(checks) == 0 { | |||
| 687 | + | return ChecksSummary{} | |||
| 688 | + | } | |||
| 689 | + | plain := make([]domain.CICheck, 0, len(checks)) | |||
| 690 | + | passed := 0 | |||
| 691 | + | for _, c := range checks { | |||
| 692 | + | plain = append(plain, c.CICheck) | |||
| 693 | + | if c.Status == domain.CICompleted && c.Conclusion.PassesMerge() { | |||
| 694 | + | passed++ | |||
| 695 | + | } | |||
| 696 | + | } | |||
| 697 | + | sum := ChecksSummary{Total: len(checks), Href: repoHref(repo.String(), "actions"), Label: fmt.Sprintf("%d/%d", passed, len(checks))} | |||
| 698 | + | switch domain.CombineChecks(plain) { | |||
| 699 | + | case domain.CheckStateSuccess: | |||
| 700 | + | sum.Kind, sum.State = "ok", "checks passed" | |||
| 701 | + | case domain.CheckStateFailure: | |||
| 702 | + | sum.Kind, sum.State = "bad", "checks failed" | |||
| 703 | + | default: | |||
| 704 | + | sum.Kind, sum.State = "running", "checks pending" | |||
| 705 | + | } | |||
| 706 | + | sum.Title = sum.State + ": " + sum.Label | |||
| 707 | + | return sum | |||
| 708 | + | } | |||
| 709 | + | ||||
| 710 | + | // checksSection is the checks of the rail of a merge request, with the rule of | |||
| 711 | + | // the repository that applies to them. | |||
| 712 | + | func checksSection(repo domain.Repo, checks []ports.CheckView, now time.Time) ChecksSection { | |||
| 713 | + | sec := ChecksSection{} | |||
| 714 | + | for _, c := range checks { | |||
| 715 | + | row := CheckRow{ | |||
| 716 | + | Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion), | |||
| 717 | + | Waiting: c.WaitingForRunner, | |||
| 718 | + | } | |||
| 719 | + | if c.JobID != "" { | |||
| 720 | + | row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID) | |||
| 721 | + | } else { | |||
| 722 | + | row.Href = runHref(repo.Name.String(), c.RunID) | |||
| 723 | + | } | |||
| 724 | + | sec.Rows = append(sec.Rows, row) | |||
| 725 | + | } | |||
| 726 | + | switch { | |||
| 727 | + | case len(checks) == 0: | |||
| 728 | + | sec.Note = "no check on this commit: nothing blocks the merge." | |||
| 729 | + | case repo.Settings.FailingChecksBlock: | |||
| 730 | + | sec.Note = "a failing or unfinished check blocks the merge." | |||
| 731 | + | default: | |||
| 732 | + | sec.Note = "checks do not block the merge in this repository." | |||
| 733 | + | } | |||
| 734 | + | _ = now | |||
| 735 | + | return sec | |||
| 736 | + | } | |||
| 737 | + | ||||
| 738 | + | // logStream is GET /<repo>/actions/runs/<id>/job/<job id>/stream?from=N: the | |||
| 739 | + | // lines of a job log from line N as Server-Sent Events, following the job while | |||
| 740 | + | // it runs. Each "ci-log" event holds the <li> of the lines of one batch and has | |||
| 741 | + | // the number of the next line as its id, so that a reconnection (Last-Event-ID) | |||
| 742 | + | // goes on where it stopped. "ci-end" tells the job is over and every line was | |||
| 743 | + | // sent. The job events of the repository stream carry no line (see events.go). | |||
| 744 | + | func (s *Server) logStream(w http.ResponseWriter, r *http.Request) { | |||
| 745 | + | ctx, cancel := context.WithCancel(r.Context()) | |||
| 746 | + | defer cancel() | |||
| 747 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |||
| 748 | + | if err != nil { | |||
| 749 | + | s.denyEvents(ctx, w, err) | |||
| 750 | + | return | |||
| 751 | + | } | |||
| 752 | + | id, err := parseRunPath(r) | |||
| 753 | + | jobID := r.PathValue("job") | |||
| 754 | + | if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id { | |||
| 755 | + | s.denyEvents(ctx, w, errNotFound) | |||
| 756 | + | return | |||
| 757 | + | } | |||
| 758 | + | from := int64(0) | |||
| 759 | + | if v := r.Header.Get("Last-Event-ID"); v != "" { | |||
| 760 | + | from, err = strconv.ParseInt(v, 10, 64) | |||
| 761 | + | } else if v := r.URL.Query().Get("from"); v != "" { | |||
| 762 | + | from, err = strconv.ParseInt(v, 10, 64) | |||
| 763 | + | } | |||
| 764 | + | if err != nil || from < 0 { | |||
| 765 | + | s.denyEvents(ctx, w, errNotFound) | |||
| 766 | + | return | |||
| 767 | + | } | |||
| 768 | + | view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0) | |||
| 769 | + | if err != nil { | |||
| 770 | + | s.denyEvents(ctx, w, err) | |||
| 771 | + | return | |||
| 772 | + | } | |||
| 773 | + | ||||
| 774 | + | rc := http.NewResponseController(w) | |||
| 775 | + | hdr := w.Header() | |||
| 776 | + | hdr.Set("Content-Type", "text/event-stream") | |||
| 777 | + | cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform") | |||
| 778 | + | hdr.Set("X-Accel-Buffering", "no") | |||
| 779 | + | w.WriteHeader(http.StatusOK) | |||
| 780 | + | out := &sseWriter{w: w, rc: rc} | |||
| 781 | + | if out.raw(": connected\n\n") != nil { | |||
| 782 | + | return | |||
| 783 | + | } | |||
| 784 | + | ranges := view.StepRanges() | |||
| 785 | + | _ = ranges | |||
| 786 | + | n := from | |||
| 787 | + | err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{ | |||
| 788 | + | JobID: jobID, Offset: from, Follow: true, | |||
| 789 | + | Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)), | |||
| 790 | + | }, func(b ports.LogBatch) error { | |||
| 791 | + | var html strings.Builder | |||
| 792 | + | for i, l := range b.Lines { | |||
| 793 | + | v := LogLineView{N: b.Offset + int64(i), Time: l.Time.UTC().Format("15:04:05"), Step: l.Step} | |||
| 794 | + | text := l.Text | |||
| 795 | + | switch { | |||
| 796 | + | case strings.HasPrefix(text, "##[group]"): | |||
| 797 | + | v.Group, text = true, strings.TrimPrefix(text, "##[group]") | |||
| 798 | + | case strings.HasPrefix(text, "##[endgroup]"): | |||
| 799 | + | v.End, text = true, strings.TrimPrefix(text, "##[endgroup]") | |||
| 800 | + | case strings.HasPrefix(text, "##[error]"): | |||
| 801 | + | v.Error, text = true, strings.TrimPrefix(text, "##[error]") | |||
| 802 | + | } | |||
| 803 | + | v.Text = visibleText(text) | |||
| 804 | + | if l.Continued { | |||
| 805 | + | continue // joined to its line by the page reload; rare and harmless | |||
| 806 | + | } | |||
| 807 | + | var b2 strings.Builder | |||
| 808 | + | if err := LogLine(v).Render(ctx, &b2); err != nil { | |||
| 809 | + | return err | |||
| 810 | + | } | |||
| 811 | + | html.WriteString(strings.ReplaceAll(b2.String(), "\n", "")) | |||
| 812 | + | } | |||
| 813 | + | n = b.Offset + int64(len(b.Lines)) | |||
| 814 | + | _ = rc.SetWriteDeadline(time.Now().Add(writeWindow)) | |||
| 815 | + | _, werr := io.WriteString(w, "event: ci-log\nid: "+strconv.FormatInt(n, 10)+"\ndata: "+html.String()+"\n\n") | |||
| 816 | + | if werr != nil { | |||
| 817 | + | return werr | |||
| 818 | + | } | |||
| 819 | + | return rc.Flush() | |||
| 820 | + | }) | |||
| 821 | + | if err == nil { | |||
| 822 | + | _ = out.raw("event: ci-end\nid: " + strconv.FormatInt(n, 10) + "\ndata: end\n\n") | |||
| 823 | + | } | |||
| 824 | + | } | |||
A internal/adapters/web/actions.templ +278 -0
| @@ -0,0 +1,278 @@ | |||
| 1 | + | package web | |
| 2 | + | ||
| 3 | + | // The CI pages: the runs of a repository, a run with its jobs, a job with its | |
| 4 | + | // steps and its log (actions.go). A state is always a word as well as a | |
| 5 | + | // symbol and a colour. | |
| 6 | + | ||
| 7 | + | // CheckIcon is the symbol of a state: a mark in the colour of its kind, with | |
| 8 | + | // the state as its text for a screen reader. | |
| 9 | + | templ CheckIcon(kind, state string) { | |
| 10 | + | <span class={ "ck", "ck-" + kind } role="img" aria-label={ state } title={ state }></span> | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | templ Actions(l Layout, p ActionsPage) { | |
| 14 | + | @Shell(l, nil) { | |
| 15 | + | if len(p.Runs) == 0 { | |
| 16 | + | <p class="dim">no runs yet: a push that matches a workflow of .github/workflows starts one.</p> | |
| 17 | + | } else { | |
| 18 | + | <table class="list runs" aria-label="Runs"> | |
| 19 | + | <thead> | |
| 20 | + | <tr><th class="c-st"></th><th class="c-name">workflow</th><th class="c-sub">commit</th><th class="c-ref">ref</th><th class="c-who">actor</th><th class="c-dur">time</th><th class="c-when">created</th></tr> | |
| 21 | + | </thead> | |
| 22 | + | <tbody id="run-rows" data-rows> | |
| 23 | + | for _, r := range p.Runs { | |
| 24 | + | @RunListRow(r) | |
| 25 | + | } | |
| 26 | + | </tbody> | |
| 27 | + | </table> | |
| 28 | + | } | |
| 29 | + | if p.More != "" { | |
| 30 | + | <p class="more"><a href={ templ.SafeURL(p.More) } rel="next">older runs</a></p> | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // RunListRow is a row of the list of runs; its id is the target of the live | |
| 36 | + | // updates. | |
| 37 | + | templ RunListRow(r RunRow) { | |
| 38 | + | <tr id={ "run-" + r.ID }> | |
| 39 | + | <td class="c-st">@CheckIcon(r.Kind, r.State)</td> | |
| 40 | + | <td class="c-name"> | |
| 41 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Workflow }</a> | |
| 42 | + | <span class="mute">{ r.Number }</span> | |
| 43 | + | if r.Attempt > 1 { | |
| 44 | + | <span class="pill">attempt { itoa(int(r.Attempt)) }</span> | |
| 45 | + | } | |
| 46 | + | </td> | |
| 47 | + | <td class="c-sub"><a href={ templ.SafeURL(r.CHref) }>{ r.Short }</a> { r.Title }</td> | |
| 48 | + | <td class="c-ref"><a href={ templ.SafeURL(r.RefHref) }>{ r.Ref }</a></td> | |
| 49 | + | <td class="c-who dim">{ r.Actor }</td> | |
| 50 | + | <td class="c-dur dim">{ r.State }{ " " }{ r.Duration }</td> | |
| 51 | + | <td class="c-when dim"> | |
| 52 | + | if r.WhenAt != "" { | |
| 53 | + | <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time> | |
| 54 | + | } | |
| 55 | + | </td> | |
| 56 | + | </tr> | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | templ RunDetail(l Layout, p RunPage) { | |
| 60 | + | @Shell(l, runRail(p)) { | |
| 61 | + | @LiveSlot("run-notice") | |
| 62 | + | <div id="run-body"> | |
| 63 | + | @RunBody(p) | |
| 64 | + | </div> | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // runBodyFragment is the body of the run page as a live fragment. | |
| 69 | + | templ runBodyFragment(p RunPage) { | |
| 70 | + | <div id="run-body"> | |
| 71 | + | @RunBody(p) | |
| 72 | + | </div> | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // RunBody is the jobs of a run, replaced by the live updates. | |
| 76 | + | templ RunBody(p RunPage) { | |
| 77 | + | <h2 class="run-h">@CheckIcon(p.Kind, p.State)<span>{ p.Workflow }</span> <span class="mute">{ p.Number }</span></h2> | |
| 78 | + | if p.Error != "" { | |
| 79 | + | <pre class="run-err">{ p.Error }</pre> | |
| 80 | + | } | |
| 81 | + | <ul class="jobs"> | |
| 82 | + | for _, j := range p.Jobs { | |
| 83 | + | <li id={ "job-" + j.ID }> | |
| 84 | + | <a class="job-h" href={ templ.SafeURL(j.Href) }> | |
| 85 | + | @CheckIcon(j.Kind, j.State) | |
| 86 | + | <span class="j-name">{ j.Name }</span> | |
| 87 | + | <span class="dim">{ j.State }{ " " }{ j.Duration }</span> | |
| 88 | + | </a> | |
| 89 | + | if j.Runner != "" { | |
| 90 | + | <span class="mute">on { runnerText(j) }</span> | |
| 91 | + | } | |
| 92 | + | if len(j.Needs) > 0 { | |
| 93 | + | <span class="mute">needs { joinWords(j.Needs) }</span> | |
| 94 | + | } | |
| 95 | + | if j.Error != "" { | |
| 96 | + | <p class="job-err">{ j.Error }</p> | |
| 97 | + | } | |
| 98 | + | if len(j.Steps) > 0 { | |
| 99 | + | <ol class="steps"> | |
| 100 | + | for _, s := range j.Steps { | |
| 101 | + | <li>@CheckIcon(s.Kind, s.State) <span>{ s.Name }</span> <span class="dim">{ s.Duration }</span></li> | |
| 102 | + | } | |
| 103 | + | </ol> | |
| 104 | + | } | |
| 105 | + | </li> | |
| 106 | + | } | |
| 107 | + | </ul> | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | templ runRail(p RunPage) { | |
| 111 | + | <section aria-labelledby="r-run"> | |
| 112 | + | <h2 id="r-run">Run</h2> | |
| 113 | + | <dl class="kv"> | |
| 114 | + | <dt>result</dt> | |
| 115 | + | <dd>{ p.State }</dd> | |
| 116 | + | <dt>commit</dt> | |
| 117 | + | <dd><a href={ templ.SafeURL(p.CHref) }>{ p.Short }</a></dd> | |
| 118 | + | <dt>ref</dt> | |
| 119 | + | <dd><a href={ templ.SafeURL(p.RefHref) }>{ p.Ref }</a></dd> | |
| 120 | + | <dt>actor</dt> | |
| 121 | + | <dd>{ p.Actor }</dd> | |
| 122 | + | <dt>attempt</dt> | |
| 123 | + | <dd>{ itoa(int(p.Attempt)) } of { itoa(int(p.Latest)) }</dd> | |
| 124 | + | </dl> | |
| 125 | + | if len(p.Attempts) > 0 { | |
| 126 | + | <ul class="attempts"> | |
| 127 | + | for _, a := range p.Attempts { | |
| 128 | + | <li> | |
| 129 | + | if a.Current { | |
| 130 | + | <span aria-current="page">{ a.Label }</span> | |
| 131 | + | } else { | |
| 132 | + | <a href={ templ.SafeURL(a.Href) }>{ a.Label }</a> | |
| 133 | + | } | |
| 134 | + | </li> | |
| 135 | + | } | |
| 136 | + | </ul> | |
| 137 | + | } | |
| 138 | + | </section> | |
| 139 | + | if p.CancelAction != "" || p.RerunAction != "" { | |
| 140 | + | <section aria-labelledby="r-act" class="decide"> | |
| 141 | + | <h2 id="r-act">Actions</h2> | |
| 142 | + | <div class="d-btns"> | |
| 143 | + | if p.CancelAction != "" { | |
| 144 | + | <form method="post" action={ templ.SafeURL(p.CancelAction) }><button type="submit" class="btn">Cancel run</button></form> | |
| 145 | + | } | |
| 146 | + | if p.RerunAction != "" { | |
| 147 | + | <form method="post" action={ templ.SafeURL(p.RerunAction) }><button type="submit" class="btn primary">Re-run all jobs</button></form> | |
| 148 | + | } | |
| 149 | + | if p.CanRerunFailed { | |
| 150 | + | <form method="post" action={ templ.SafeURL(p.RerunFailedAction) }><button type="submit" class="btn">Re-run failed jobs</button></form> | |
| 151 | + | } | |
| 152 | + | </div> | |
| 153 | + | </section> | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // JobHead is the title of the job page with its state and its error, replaced | |
| 158 | + | // by the live updates. total is the number of lines the log holds. | |
| 159 | + | templ JobHead(j JobRow, total int64) { | |
| 160 | + | <div id="job-head" data-total={ itoa64(total) }> | |
| 161 | + | <h2 class="run-h">@CheckIcon(j.Kind, j.State)<span>{ j.Name }</span> <span class="dim">{ j.State }{ " " }{ j.Duration }</span></h2> | |
| 162 | + | if j.Error != "" { | |
| 163 | + | <p class="job-err">{ j.Error }</p> | |
| 164 | + | } | |
| 165 | + | </div> | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // JobSteps is the steps of the job in the rail, replaced by the live updates. | |
| 169 | + | templ JobSteps(steps []StepView) { | |
| 170 | + | <ol class="steps" id="job-steps"> | |
| 171 | + | for _, s := range steps { | |
| 172 | + | <li> | |
| 173 | + | @CheckIcon(s.Kind, s.State) | |
| 174 | + | if s.LogEnd > s.LogStart { | |
| 175 | + | <a href={ templ.SafeURL("#L" + itoa64(s.LogStart)) }>{ s.Name }</a> | |
| 176 | + | } else { | |
| 177 | + | <span>{ s.Name }</span> | |
| 178 | + | } | |
| 179 | + | <span class="dim">{ s.Duration }</span> | |
| 180 | + | </li> | |
| 181 | + | } | |
| 182 | + | </ol> | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | templ Job(l Layout, p JobPage) { | |
| 186 | + | @Shell(l, jobRail(p)) { | |
| 187 | + | @LiveSlot("job-notice") | |
| 188 | + | @JobHead(p.JobRow, p.Total) | |
| 189 | + | <div id="job-log" class="joblog" data-total={ itoa64(p.Total) } data-follow?={ p.Follow } data-stream={ p.StreamHref } data-next={ itoa64(p.From + int64(len(p.Lines))) }> | |
| 190 | + | if p.Note != "" { | |
| 191 | + | <p class="dim">{ p.Note }</p> | |
| 192 | + | } | |
| 193 | + | if p.Prev != "" { | |
| 194 | + | <p class="more"><a href={ templ.SafeURL(p.Prev) } rel="prev">earlier lines</a></p> | |
| 195 | + | } | |
| 196 | + | <ol class="log" start={ itoa64(p.From + 1) }> | |
| 197 | + | for _, ln := range p.Lines { | |
| 198 | + | @LogLine(ln) | |
| 199 | + | } | |
| 200 | + | </ol> | |
| 201 | + | if p.Next != "" { | |
| 202 | + | <p class="more"><a href={ templ.SafeURL(p.Next) } rel="next">more lines</a></p> | |
| 203 | + | } | |
| 204 | + | </div> | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // LogLine is a line of the log. Group and error markers of the runner show as | |
| 209 | + | // headings and errors, never as raw text. | |
| 210 | + | templ LogLine(ln LogLineView) { | |
| 211 | + | <li id={ "L" + itoa64(ln.N) } class={ logClass(ln) } data-step={ itoa(int(ln.Step)) }><time>{ ln.Time }</time><span class="t">{ ln.Text }</span></li> | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | templ jobRail(p JobPage) { | |
| 215 | + | <section aria-labelledby="r-job"> | |
| 216 | + | <h2 id="r-job">Steps</h2> | |
| 217 | + | @JobSteps(p.Steps) | |
| 218 | + | </section> | |
| 219 | + | <section aria-labelledby="r-jobf"> | |
| 220 | + | <h2 id="r-jobf">Job</h2> | |
| 221 | + | <dl class="kv"> | |
| 222 | + | <dt>run</dt> | |
| 223 | + | <dd><a href={ templ.SafeURL(p.RunHref) }>{ p.RunName }</a></dd> | |
| 224 | + | if p.Runner != "" { | |
| 225 | + | <dt>runner</dt> | |
| 226 | + | <dd>{ p.Runner }</dd> | |
| 227 | + | } | |
| 228 | + | if p.Isolation != "" { | |
| 229 | + | <dt>isolation</dt> | |
| 230 | + | <dd>{ p.Isolation }</dd> | |
| 231 | + | } | |
| 232 | + | <dt>lines</dt> | |
| 233 | + | <dd>{ itoa64(p.Total) }</dd> | |
| 234 | + | </dl> | |
| 235 | + | <p><a href={ templ.SafeURL(p.RawHref) }>raw log</a></p> | |
| 236 | + | </section> | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // CommitChecks is the combined state of the checks of a commit, as a mark | |
| 240 | + | // with a count, linking to the page that explains it. | |
| 241 | + | templ CommitChecks(c ChecksSummary) { | |
| 242 | + | if c.Total > 0 { | |
| 243 | + | <a class="ck-sum" href={ templ.SafeURL(c.Href) } title={ c.Title }> | |
| 244 | + | @CheckIcon(c.Kind, c.State) | |
| 245 | + | <span class="mute">{ c.Label }</span> | |
| 246 | + | </a> | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // MRChecks is the checks section of the rail of a merge request. | |
| 251 | + | templ MRChecks(c ChecksSection) { | |
| 252 | + | <section aria-labelledby="r-checks" id="mr-checks"> | |
| 253 | + | <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2> | |
| 254 | + | if len(c.Rows) == 0 { | |
| 255 | + | <p class="dim">no checks</p> | |
| 256 | + | } else { | |
| 257 | + | <ul class="checks"> | |
| 258 | + | for _, r := range c.Rows { | |
| 259 | + | <li> | |
| 260 | + | @CheckIcon(r.Kind, r.State) | |
| 261 | + | if r.Href != "" { | |
| 262 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | |
| 263 | + | } else { | |
| 264 | + | <span>{ r.Name }</span> | |
| 265 | + | } | |
| 266 | + | <span class="dim">{ r.State }</span> | |
| 267 | + | if r.Waiting { | |
| 268 | + | <span class="mute">waiting for a runner</span> | |
| 269 | + | } | |
| 270 | + | </li> | |
| 271 | + | } | |
| 272 | + | </ul> | |
| 273 | + | } | |
| 274 | + | if c.Note != "" { | |
| 275 | + | <p class="mute">{ c.Note }</p> | |
| 276 | + | } | |
| 277 | + | </section> | |
| 278 | + | } | |
| @@ -0,0 +1,278 @@ | |||||
| 1 | + | package web | |||
| 2 | + | ||||
| 3 | + | // The CI pages: the runs of a repository, a run with its jobs, a job with its | |||
| 4 | + | // steps and its log (actions.go). A state is always a word as well as a | |||
| 5 | + | // symbol and a colour. | |||
| 6 | + | ||||
| 7 | + | // CheckIcon is the symbol of a state: a mark in the colour of its kind, with | |||
| 8 | + | // the state as its text for a screen reader. | |||
| 9 | + | templ CheckIcon(kind, state string) { | |||
| 10 | + | <span class={ "ck", "ck-" + kind } role="img" aria-label={ state } title={ state }></span> | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | templ Actions(l Layout, p ActionsPage) { | |||
| 14 | + | @Shell(l, nil) { | |||
| 15 | + | if len(p.Runs) == 0 { | |||
| 16 | + | <p class="dim">no runs yet: a push that matches a workflow of .github/workflows starts one.</p> | |||
| 17 | + | } else { | |||
| 18 | + | <table class="list runs" aria-label="Runs"> | |||
| 19 | + | <thead> | |||
| 20 | + | <tr><th class="c-st"></th><th class="c-name">workflow</th><th class="c-sub">commit</th><th class="c-ref">ref</th><th class="c-who">actor</th><th class="c-dur">time</th><th class="c-when">created</th></tr> | |||
| 21 | + | </thead> | |||
| 22 | + | <tbody id="run-rows" data-rows> | |||
| 23 | + | for _, r := range p.Runs { | |||
| 24 | + | @RunListRow(r) | |||
| 25 | + | } | |||
| 26 | + | </tbody> | |||
| 27 | + | </table> | |||
| 28 | + | } | |||
| 29 | + | if p.More != "" { | |||
| 30 | + | <p class="more"><a href={ templ.SafeURL(p.More) } rel="next">older runs</a></p> | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // RunListRow is a row of the list of runs; its id is the target of the live | |||
| 36 | + | // updates. | |||
| 37 | + | templ RunListRow(r RunRow) { | |||
| 38 | + | <tr id={ "run-" + r.ID }> | |||
| 39 | + | <td class="c-st">@CheckIcon(r.Kind, r.State)</td> | |||
| 40 | + | <td class="c-name"> | |||
| 41 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Workflow }</a> | |||
| 42 | + | <span class="mute">{ r.Number }</span> | |||
| 43 | + | if r.Attempt > 1 { | |||
| 44 | + | <span class="pill">attempt { itoa(int(r.Attempt)) }</span> | |||
| 45 | + | } | |||
| 46 | + | </td> | |||
| 47 | + | <td class="c-sub"><a href={ templ.SafeURL(r.CHref) }>{ r.Short }</a> { r.Title }</td> | |||
| 48 | + | <td class="c-ref"><a href={ templ.SafeURL(r.RefHref) }>{ r.Ref }</a></td> | |||
| 49 | + | <td class="c-who dim">{ r.Actor }</td> | |||
| 50 | + | <td class="c-dur dim">{ r.State }{ " " }{ r.Duration }</td> | |||
| 51 | + | <td class="c-when dim"> | |||
| 52 | + | if r.WhenAt != "" { | |||
| 53 | + | <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time> | |||
| 54 | + | } | |||
| 55 | + | </td> | |||
| 56 | + | </tr> | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | templ RunDetail(l Layout, p RunPage) { | |||
| 60 | + | @Shell(l, runRail(p)) { | |||
| 61 | + | @LiveSlot("run-notice") | |||
| 62 | + | <div id="run-body"> | |||
| 63 | + | @RunBody(p) | |||
| 64 | + | </div> | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // runBodyFragment is the body of the run page as a live fragment. | |||
| 69 | + | templ runBodyFragment(p RunPage) { | |||
| 70 | + | <div id="run-body"> | |||
| 71 | + | @RunBody(p) | |||
| 72 | + | </div> | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // RunBody is the jobs of a run, replaced by the live updates. | |||
| 76 | + | templ RunBody(p RunPage) { | |||
| 77 | + | <h2 class="run-h">@CheckIcon(p.Kind, p.State)<span>{ p.Workflow }</span> <span class="mute">{ p.Number }</span></h2> | |||
| 78 | + | if p.Error != "" { | |||
| 79 | + | <pre class="run-err">{ p.Error }</pre> | |||
| 80 | + | } | |||
| 81 | + | <ul class="jobs"> | |||
| 82 | + | for _, j := range p.Jobs { | |||
| 83 | + | <li id={ "job-" + j.ID }> | |||
| 84 | + | <a class="job-h" href={ templ.SafeURL(j.Href) }> | |||
| 85 | + | @CheckIcon(j.Kind, j.State) | |||
| 86 | + | <span class="j-name">{ j.Name }</span> | |||
| 87 | + | <span class="dim">{ j.State }{ " " }{ j.Duration }</span> | |||
| 88 | + | </a> | |||
| 89 | + | if j.Runner != "" { | |||
| 90 | + | <span class="mute">on { runnerText(j) }</span> | |||
| 91 | + | } | |||
| 92 | + | if len(j.Needs) > 0 { | |||
| 93 | + | <span class="mute">needs { joinWords(j.Needs) }</span> | |||
| 94 | + | } | |||
| 95 | + | if j.Error != "" { | |||
| 96 | + | <p class="job-err">{ j.Error }</p> | |||
| 97 | + | } | |||
| 98 | + | if len(j.Steps) > 0 { | |||
| 99 | + | <ol class="steps"> | |||
| 100 | + | for _, s := range j.Steps { | |||
| 101 | + | <li>@CheckIcon(s.Kind, s.State) <span>{ s.Name }</span> <span class="dim">{ s.Duration }</span></li> | |||
| 102 | + | } | |||
| 103 | + | </ol> | |||
| 104 | + | } | |||
| 105 | + | </li> | |||
| 106 | + | } | |||
| 107 | + | </ul> | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | templ runRail(p RunPage) { | |||
| 111 | + | <section aria-labelledby="r-run"> | |||
| 112 | + | <h2 id="r-run">Run</h2> | |||
| 113 | + | <dl class="kv"> | |||
| 114 | + | <dt>result</dt> | |||
| 115 | + | <dd>{ p.State }</dd> | |||
| 116 | + | <dt>commit</dt> | |||
| 117 | + | <dd><a href={ templ.SafeURL(p.CHref) }>{ p.Short }</a></dd> | |||
| 118 | + | <dt>ref</dt> | |||
| 119 | + | <dd><a href={ templ.SafeURL(p.RefHref) }>{ p.Ref }</a></dd> | |||
| 120 | + | <dt>actor</dt> | |||
| 121 | + | <dd>{ p.Actor }</dd> | |||
| 122 | + | <dt>attempt</dt> | |||
| 123 | + | <dd>{ itoa(int(p.Attempt)) } of { itoa(int(p.Latest)) }</dd> | |||
| 124 | + | </dl> | |||
| 125 | + | if len(p.Attempts) > 0 { | |||
| 126 | + | <ul class="attempts"> | |||
| 127 | + | for _, a := range p.Attempts { | |||
| 128 | + | <li> | |||
| 129 | + | if a.Current { | |||
| 130 | + | <span aria-current="page">{ a.Label }</span> | |||
| 131 | + | } else { | |||
| 132 | + | <a href={ templ.SafeURL(a.Href) }>{ a.Label }</a> | |||
| 133 | + | } | |||
| 134 | + | </li> | |||
| 135 | + | } | |||
| 136 | + | </ul> | |||
| 137 | + | } | |||
| 138 | + | </section> | |||
| 139 | + | if p.CancelAction != "" || p.RerunAction != "" { | |||
| 140 | + | <section aria-labelledby="r-act" class="decide"> | |||
| 141 | + | <h2 id="r-act">Actions</h2> | |||
| 142 | + | <div class="d-btns"> | |||
| 143 | + | if p.CancelAction != "" { | |||
| 144 | + | <form method="post" action={ templ.SafeURL(p.CancelAction) }><button type="submit" class="btn">Cancel run</button></form> | |||
| 145 | + | } | |||
| 146 | + | if p.RerunAction != "" { | |||
| 147 | + | <form method="post" action={ templ.SafeURL(p.RerunAction) }><button type="submit" class="btn primary">Re-run all jobs</button></form> | |||
| 148 | + | } | |||
| 149 | + | if p.CanRerunFailed { | |||
| 150 | + | <form method="post" action={ templ.SafeURL(p.RerunFailedAction) }><button type="submit" class="btn">Re-run failed jobs</button></form> | |||
| 151 | + | } | |||
| 152 | + | </div> | |||
| 153 | + | </section> | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // JobHead is the title of the job page with its state and its error, replaced | |||
| 158 | + | // by the live updates. total is the number of lines the log holds. | |||
| 159 | + | templ JobHead(j JobRow, total int64) { | |||
| 160 | + | <div id="job-head" data-total={ itoa64(total) }> | |||
| 161 | + | <h2 class="run-h">@CheckIcon(j.Kind, j.State)<span>{ j.Name }</span> <span class="dim">{ j.State }{ " " }{ j.Duration }</span></h2> | |||
| 162 | + | if j.Error != "" { | |||
| 163 | + | <p class="job-err">{ j.Error }</p> | |||
| 164 | + | } | |||
| 165 | + | </div> | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // JobSteps is the steps of the job in the rail, replaced by the live updates. | |||
| 169 | + | templ JobSteps(steps []StepView) { | |||
| 170 | + | <ol class="steps" id="job-steps"> | |||
| 171 | + | for _, s := range steps { | |||
| 172 | + | <li> | |||
| 173 | + | @CheckIcon(s.Kind, s.State) | |||
| 174 | + | if s.LogEnd > s.LogStart { | |||
| 175 | + | <a href={ templ.SafeURL("#L" + itoa64(s.LogStart)) }>{ s.Name }</a> | |||
| 176 | + | } else { | |||
| 177 | + | <span>{ s.Name }</span> | |||
| 178 | + | } | |||
| 179 | + | <span class="dim">{ s.Duration }</span> | |||
| 180 | + | </li> | |||
| 181 | + | } | |||
| 182 | + | </ol> | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | templ Job(l Layout, p JobPage) { | |||
| 186 | + | @Shell(l, jobRail(p)) { | |||
| 187 | + | @LiveSlot("job-notice") | |||
| 188 | + | @JobHead(p.JobRow, p.Total) | |||
| 189 | + | <div id="job-log" class="joblog" data-total={ itoa64(p.Total) } data-follow?={ p.Follow } data-stream={ p.StreamHref } data-next={ itoa64(p.From + int64(len(p.Lines))) }> | |||
| 190 | + | if p.Note != "" { | |||
| 191 | + | <p class="dim">{ p.Note }</p> | |||
| 192 | + | } | |||
| 193 | + | if p.Prev != "" { | |||
| 194 | + | <p class="more"><a href={ templ.SafeURL(p.Prev) } rel="prev">earlier lines</a></p> | |||
| 195 | + | } | |||
| 196 | + | <ol class="log" start={ itoa64(p.From + 1) }> | |||
| 197 | + | for _, ln := range p.Lines { | |||
| 198 | + | @LogLine(ln) | |||
| 199 | + | } | |||
| 200 | + | </ol> | |||
| 201 | + | if p.Next != "" { | |||
| 202 | + | <p class="more"><a href={ templ.SafeURL(p.Next) } rel="next">more lines</a></p> | |||
| 203 | + | } | |||
| 204 | + | </div> | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | // LogLine is a line of the log. Group and error markers of the runner show as | |||
| 209 | + | // headings and errors, never as raw text. | |||
| 210 | + | templ LogLine(ln LogLineView) { | |||
| 211 | + | <li id={ "L" + itoa64(ln.N) } class={ logClass(ln) } data-step={ itoa(int(ln.Step)) }><time>{ ln.Time }</time><span class="t">{ ln.Text }</span></li> | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | templ jobRail(p JobPage) { | |||
| 215 | + | <section aria-labelledby="r-job"> | |||
| 216 | + | <h2 id="r-job">Steps</h2> | |||
| 217 | + | @JobSteps(p.Steps) | |||
| 218 | + | </section> | |||
| 219 | + | <section aria-labelledby="r-jobf"> | |||
| 220 | + | <h2 id="r-jobf">Job</h2> | |||
| 221 | + | <dl class="kv"> | |||
| 222 | + | <dt>run</dt> | |||
| 223 | + | <dd><a href={ templ.SafeURL(p.RunHref) }>{ p.RunName }</a></dd> | |||
| 224 | + | if p.Runner != "" { | |||
| 225 | + | <dt>runner</dt> | |||
| 226 | + | <dd>{ p.Runner }</dd> | |||
| 227 | + | } | |||
| 228 | + | if p.Isolation != "" { | |||
| 229 | + | <dt>isolation</dt> | |||
| 230 | + | <dd>{ p.Isolation }</dd> | |||
| 231 | + | } | |||
| 232 | + | <dt>lines</dt> | |||
| 233 | + | <dd>{ itoa64(p.Total) }</dd> | |||
| 234 | + | </dl> | |||
| 235 | + | <p><a href={ templ.SafeURL(p.RawHref) }>raw log</a></p> | |||
| 236 | + | </section> | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // CommitChecks is the combined state of the checks of a commit, as a mark | |||
| 240 | + | // with a count, linking to the page that explains it. | |||
| 241 | + | templ CommitChecks(c ChecksSummary) { | |||
| 242 | + | if c.Total > 0 { | |||
| 243 | + | <a class="ck-sum" href={ templ.SafeURL(c.Href) } title={ c.Title }> | |||
| 244 | + | @CheckIcon(c.Kind, c.State) | |||
| 245 | + | <span class="mute">{ c.Label }</span> | |||
| 246 | + | </a> | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // MRChecks is the checks section of the rail of a merge request. | |||
| 251 | + | templ MRChecks(c ChecksSection) { | |||
| 252 | + | <section aria-labelledby="r-checks" id="mr-checks"> | |||
| 253 | + | <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2> | |||
| 254 | + | if len(c.Rows) == 0 { | |||
| 255 | + | <p class="dim">no checks</p> | |||
| 256 | + | } else { | |||
| 257 | + | <ul class="checks"> | |||
| 258 | + | for _, r := range c.Rows { | |||
| 259 | + | <li> | |||
| 260 | + | @CheckIcon(r.Kind, r.State) | |||
| 261 | + | if r.Href != "" { | |||
| 262 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | |||
| 263 | + | } else { | |||
| 264 | + | <span>{ r.Name }</span> | |||
| 265 | + | } | |||
| 266 | + | <span class="dim">{ r.State }</span> | |||
| 267 | + | if r.Waiting { | |||
| 268 | + | <span class="mute">waiting for a runner</span> | |||
| 269 | + | } | |||
| 270 | + | </li> | |||
| 271 | + | } | |||
| 272 | + | </ul> | |||
| 273 | + | } | |||
| 274 | + | if c.Note != "" { | |||
| 275 | + | <p class="mute">{ c.Note }</p> | |||
| 276 | + | } | |||
| 277 | + | </section> | |||
| 278 | + | } | |||
A internal/adapters/web/actions_templ.go +2012 -0 generated file: collapsed
M internal/adapters/web/commit.go +3 -0
| @@ -44,6 +44,8 @@ type CommitPage struct { | |||
| 44 | 44 | // Invisible is true when some line holds an invisible character, shown as a | |
| 45 | 45 | // marker. | |
| 46 | 46 | Invisible bool | |
| 47 | + | // Checks is the combined state of the CI checks of the commit. | |
| 48 | + | Checks ChecksSummary | |
| 47 | 49 | } | |
| 48 | 50 | ||
| 49 | 51 | // DiffView is how a page lays its diff out: the layouts in the page and the | |
| @@ -138,6 +140,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||
| 138 | 140 | } | |
| 139 | 141 | } | |
| 140 | 142 | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |
| 143 | + | p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid)) | |
| 141 | 144 | ||
| 142 | 145 | lines, invisible := diffLines(p.Files) | |
| 143 | 146 | p.Invisible = invisible | |
| @@ -44,6 +44,8 @@ type CommitPage struct { | |||||
| 44 | // Invisible is true when some line holds an invisible character, shown as a | 44 | // Invisible is true when some line holds an invisible character, shown as a | ||
| 45 | // marker. | 45 | // marker. | ||
| 46 | Invisible bool | 46 | Invisible bool | ||
| 47 | + | // Checks is the combined state of the CI checks of the commit. | |||
| 48 | + | Checks ChecksSummary | |||
| 47 | } | 49 | } | ||
| 48 | 50 | ||||
| 49 | // DiffView is how a page lays its diff out: the layouts in the page and the | 51 | // DiffView is how a page lays its diff out: the layouts in the page and the | ||
| @@ -138,6 +140,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 138 | } | 140 | } | ||
| 139 | } | 141 | } | ||
| 140 | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | 142 | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | ||
| 143 | + | p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid)) | |||
| 141 | 144 | ||||
| 142 | lines, invisible := diffLines(p.Files) | 145 | lines, invisible := diffLines(p.Files) | ||
| 143 | p.Invisible = invisible | 146 | p.Invisible = invisible | ||
M internal/adapters/web/commits.go +9 -2
| @@ -73,7 +73,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||
| 73 | 73 | if token != "" { | |
| 74 | 74 | p.Newest = base | |
| 75 | 75 | } | |
| 76 | - | s.fillDays(&p, d, cs) | |
| 76 | + | s.fillDays(ctx, &p, d, cs) | |
| 77 | 77 | if sel.path != "" && token == "" { | |
| 78 | 78 | p.Open = s.pathLink(ctx, d, sel) | |
| 79 | 79 | } | |
| @@ -95,14 +95,21 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||
| 95 | 95 | return Commits(l, p), http.StatusOK, nil | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | + | // maxCheckLookups bounds the commits of one page whose checks are read: each | |
| 99 | + | // is one read of the store. | |
| 100 | + | const maxCheckLookups = 100 | |
| 101 | + | ||
| 98 | 102 | // fillDays groups the commits of a page by day and lists the refs among them. | |
| 99 | - | func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []domain.Commit) { | |
| 103 | + | func (s *Server) fillDays(ctx context.Context, p *CommitsPage, d *repoData, cs []domain.Commit) { | |
| 100 | 104 | now := s.cfg.Now() | |
| 101 | 105 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | |
| 102 | 106 | for i, c := range cs { | |
| 103 | 107 | row := commitRow(d.name(), c, now) | |
| 104 | 108 | row.ID, row.Cursor = "c-"+row.Short, i == 0 | |
| 105 | 109 | row.Branches, row.Tags = branchesAt[c.ID.String()], tagsAt[c.ID.String()] | |
| 110 | + | if i < maxCheckLookups { | |
| 111 | + | row.Checks = checksSummary(d.repo.Name, c.ID.String(), s.checksFor(ctx, d.repo.Name, c.ID.String())) | |
| 112 | + | } | |
| 106 | 113 | day := "unknown date" | |
| 107 | 114 | if t := c.Committer.Time; !t.IsZero() { | |
| 108 | 115 | day = t.UTC().Format("2006-01-02") | |
| @@ -73,7 +73,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 73 | if token != "" { | 73 | if token != "" { | ||
| 74 | p.Newest = base | 74 | p.Newest = base | ||
| 75 | } | 75 | } | ||
| 76 | - | s.fillDays(&p, d, cs) | 76 | + | s.fillDays(ctx, &p, d, cs) |
| 77 | if sel.path != "" && token == "" { | 77 | if sel.path != "" && token == "" { | ||
| 78 | p.Open = s.pathLink(ctx, d, sel) | 78 | p.Open = s.pathLink(ctx, d, sel) | ||
| 79 | } | 79 | } | ||
| @@ -95,14 +95,21 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 95 | return Commits(l, p), http.StatusOK, nil | 95 | return Commits(l, p), http.StatusOK, nil | ||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | + | // maxCheckLookups bounds the commits of one page whose checks are read: each | |||
| 99 | + | // is one read of the store. | |||
| 100 | + | const maxCheckLookups = 100 | |||
| 101 | + | ||||
| 98 | // fillDays groups the commits of a page by day and lists the refs among them. | 102 | // fillDays groups the commits of a page by day and lists the refs among them. | ||
| 99 | - | func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []domain.Commit) { | 103 | + | func (s *Server) fillDays(ctx context.Context, p *CommitsPage, d *repoData, cs []domain.Commit) { |
| 100 | now := s.cfg.Now() | 104 | now := s.cfg.Now() | ||
| 101 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | 105 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | ||
| 102 | for i, c := range cs { | 106 | for i, c := range cs { | ||
| 103 | row := commitRow(d.name(), c, now) | 107 | row := commitRow(d.name(), c, now) | ||
| 104 | row.ID, row.Cursor = "c-"+row.Short, i == 0 | 108 | row.ID, row.Cursor = "c-"+row.Short, i == 0 | ||
| 105 | row.Branches, row.Tags = branchesAt[c.ID.String()], tagsAt[c.ID.String()] | 109 | row.Branches, row.Tags = branchesAt[c.ID.String()], tagsAt[c.ID.String()] | ||
| 110 | + | if i < maxCheckLookups { | |||
| 111 | + | row.Checks = checksSummary(d.repo.Name, c.ID.String(), s.checksFor(ctx, d.repo.Name, c.ID.String())) | |||
| 112 | + | } | |||
| 106 | day := "unknown date" | 113 | day := "unknown date" | ||
| 107 | if t := c.Committer.Time; !t.IsZero() { | 114 | if t := c.Committer.Time; !t.IsZero() { | ||
| 108 | day = t.UTC().Format("2006-01-02") | 115 | day = t.UTC().Format("2006-01-02") | ||
M internal/adapters/web/components.templ +1 -0
| @@ -112,6 +112,7 @@ templ CommitRows(rows []CommitRow) { | |||
| 112 | 112 | <span class="pill tag"><span class="sr">tag </span>{ t }</span> | |
| 113 | 113 | } | |
| 114 | 114 | </td> | |
| 115 | + | <td class="c-ck">@CommitChecks(c.Checks)</td> | |
| 115 | 116 | <td class="c-who dim">{ c.Author }</td> | |
| 116 | 117 | <td class="c-when nowrap dim"><time datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time></td> | |
| 117 | 118 | </tr> | |
| @@ -112,6 +112,7 @@ templ CommitRows(rows []CommitRow) { | |||||
| 112 | <span class="pill tag"><span class="sr">tag </span>{ t }</span> | 112 | <span class="pill tag"><span class="sr">tag </span>{ t }</span> | ||
| 113 | } | 113 | } | ||
| 114 | </td> | 114 | </td> | ||
| 115 | + | <td class="c-ck">@CommitChecks(c.Checks)</td> | |||
| 115 | <td class="c-who dim">{ c.Author }</td> | 116 | <td class="c-who dim">{ c.Author }</td> | ||
| 116 | <td class="c-when nowrap dim"><time datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time></td> | 117 | <td class="c-when nowrap dim"><time datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time></td> | ||
| 117 | </tr> | 118 | </tr> | ||
M internal/adapters/web/components_templ.go +18 -10
| @@ -649,64 +649,72 @@ func CommitRows(rows []CommitRow) templ.Component { | |||
| 649 | 649 | return templ_7745c5c3_Err | |
| 650 | 650 | } | |
| 651 | 651 | } | |
| 652 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</td><td class=\"c-who dim\">") | |
| 652 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</td><td class=\"c-ck\">") | |
| 653 | + | if templ_7745c5c3_Err != nil { | |
| 654 | + | return templ_7745c5c3_Err | |
| 655 | + | } | |
| 656 | + | templ_7745c5c3_Err = CommitChecks(c.Checks).Render(ctx, templ_7745c5c3_Buffer) | |
| 657 | + | if templ_7745c5c3_Err != nil { | |
| 658 | + | return templ_7745c5c3_Err | |
| 659 | + | } | |
| 660 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td><td class=\"c-who dim\">") | |
| 653 | 661 | if templ_7745c5c3_Err != nil { | |
| 654 | 662 | return templ_7745c5c3_Err | |
| 655 | 663 | } | |
| 656 | 664 | var templ_7745c5c3_Var32 string | |
| 657 | 665 | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author) | |
| 658 | 666 | if templ_7745c5c3_Err != nil { | |
| 659 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 115, Col: 37} | |
| 667 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 116, Col: 37} | |
| 660 | 668 | } | |
| 661 | 669 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) | |
| 662 | 670 | if templ_7745c5c3_Err != nil { | |
| 663 | 671 | return templ_7745c5c3_Err | |
| 664 | 672 | } | |
| 665 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td><td class=\"c-when nowrap dim\"><time datetime=\"") | |
| 673 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"c-when nowrap dim\"><time datetime=\"") | |
| 666 | 674 | if templ_7745c5c3_Err != nil { | |
| 667 | 675 | return templ_7745c5c3_Err | |
| 668 | 676 | } | |
| 669 | 677 | var templ_7745c5c3_Var33 string | |
| 670 | 678 | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt) | |
| 671 | 679 | if templ_7745c5c3_Err != nil { | |
| 672 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 116, Col: 60} | |
| 680 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 117, Col: 60} | |
| 673 | 681 | } | |
| 674 | 682 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33) | |
| 675 | 683 | if templ_7745c5c3_Err != nil { | |
| 676 | 684 | return templ_7745c5c3_Err | |
| 677 | 685 | } | |
| 678 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" title=\"") | |
| 686 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" title=\"") | |
| 679 | 687 | if templ_7745c5c3_Err != nil { | |
| 680 | 688 | return templ_7745c5c3_Err | |
| 681 | 689 | } | |
| 682 | 690 | var templ_7745c5c3_Var34 string | |
| 683 | 691 | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt) | |
| 684 | 692 | if templ_7745c5c3_Err != nil { | |
| 685 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 116, Col: 79} | |
| 693 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 117, Col: 79} | |
| 686 | 694 | } | |
| 687 | 695 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) | |
| 688 | 696 | if templ_7745c5c3_Err != nil { | |
| 689 | 697 | return templ_7745c5c3_Err | |
| 690 | 698 | } | |
| 691 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">") | |
| 699 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\">") | |
| 692 | 700 | if templ_7745c5c3_Err != nil { | |
| 693 | 701 | return templ_7745c5c3_Err | |
| 694 | 702 | } | |
| 695 | 703 | var templ_7745c5c3_Var35 string | |
| 696 | 704 | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.When) | |
| 697 | 705 | if templ_7745c5c3_Err != nil { | |
| 698 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 116, Col: 90} | |
| 706 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 117, Col: 90} | |
| 699 | 707 | } | |
| 700 | 708 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) | |
| 701 | 709 | if templ_7745c5c3_Err != nil { | |
| 702 | 710 | return templ_7745c5c3_Err | |
| 703 | 711 | } | |
| 704 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</time></td></tr>") | |
| 712 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</time></td></tr>") | |
| 705 | 713 | if templ_7745c5c3_Err != nil { | |
| 706 | 714 | return templ_7745c5c3_Err | |
| 707 | 715 | } | |
| 708 | 716 | } | |
| 709 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</tbody></table>") | |
| 717 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</tbody></table>") | |
| 710 | 718 | if templ_7745c5c3_Err != nil { | |
| 711 | 719 | return templ_7745c5c3_Err | |
| 712 | 720 | } | |
| @@ -649,64 +649,72 @@ func CommitRows(rows []CommitRow) templ.Component { | |||||
| 649 | return templ_7745c5c3_Err | 649 | return templ_7745c5c3_Err | ||
| 650 | } | 650 | } | ||
| 651 | } | 651 | } | ||
| 652 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</td><td class=\"c-who dim\">") | 652 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</td><td class=\"c-ck\">") |
| 653 | + | if templ_7745c5c3_Err != nil { | |||
| 654 | + | return templ_7745c5c3_Err | |||
| 655 | + | } | |||
| 656 | + | templ_7745c5c3_Err = CommitChecks(c.Checks).Render(ctx, templ_7745c5c3_Buffer) | |||
| 657 | + | if templ_7745c5c3_Err != nil { | |||
| 658 | + | return templ_7745c5c3_Err | |||
| 659 | + | } | |||
| 660 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td><td class=\"c-who dim\">") | |||
| 653 | if templ_7745c5c3_Err != nil { | 661 | if templ_7745c5c3_Err != nil { | ||
| 654 | return templ_7745c5c3_Err | 662 | return templ_7745c5c3_Err | ||
| 655 | } | 663 | } | ||
| 656 | var templ_7745c5c3_Var32 string | 664 | var templ_7745c5c3_Var32 string | ||
| 657 | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author) | 665 | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author) | ||
| 658 | if templ_7745c5c3_Err != nil { | 666 | if templ_7745c5c3_Err != nil { | ||
| 659 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 115, Col: 37} | 667 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 116, Col: 37} |
| 660 | } | 668 | } | ||
| 661 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) | 669 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) | ||
| 662 | if templ_7745c5c3_Err != nil { | 670 | if templ_7745c5c3_Err != nil { | ||
| 663 | return templ_7745c5c3_Err | 671 | return templ_7745c5c3_Err | ||
| 664 | } | 672 | } | ||
| 665 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td><td class=\"c-when nowrap dim\"><time datetime=\"") | 673 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"c-when nowrap dim\"><time datetime=\"") |
| 666 | if templ_7745c5c3_Err != nil { | 674 | if templ_7745c5c3_Err != nil { | ||
| 667 | return templ_7745c5c3_Err | 675 | return templ_7745c5c3_Err | ||
| 668 | } | 676 | } | ||
| 669 | var templ_7745c5c3_Var33 string | 677 | var templ_7745c5c3_Var33 string | ||
| 670 | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt) | 678 | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt) | ||
| 671 | if templ_7745c5c3_Err != nil { | 679 | if templ_7745c5c3_Err != nil { | ||
| 672 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 116, Col: 60} | 680 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 117, Col: 60} |
| 673 | } | 681 | } | ||
| 674 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33) | 682 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33) | ||
| 675 | if templ_7745c5c3_Err != nil { | 683 | if templ_7745c5c3_Err != nil { | ||
| 676 | return templ_7745c5c3_Err | 684 | return templ_7745c5c3_Err | ||
| 677 | } | 685 | } | ||
| 678 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" title=\"") | 686 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" title=\"") |
| 679 | if templ_7745c5c3_Err != nil { | 687 | if templ_7745c5c3_Err != nil { | ||
| 680 | return templ_7745c5c3_Err | 688 | return templ_7745c5c3_Err | ||
| 681 | } | 689 | } | ||
| 682 | var templ_7745c5c3_Var34 string | 690 | var templ_7745c5c3_Var34 string | ||
| 683 | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt) | 691 | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt) | ||
| 684 | if templ_7745c5c3_Err != nil { | 692 | if templ_7745c5c3_Err != nil { | ||
| 685 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 116, Col: 79} | 693 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 117, Col: 79} |
| 686 | } | 694 | } | ||
| 687 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) | 695 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) | ||
| 688 | if templ_7745c5c3_Err != nil { | 696 | if templ_7745c5c3_Err != nil { | ||
| 689 | return templ_7745c5c3_Err | 697 | return templ_7745c5c3_Err | ||
| 690 | } | 698 | } | ||
| 691 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">") | 699 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\">") |
| 692 | if templ_7745c5c3_Err != nil { | 700 | if templ_7745c5c3_Err != nil { | ||
| 693 | return templ_7745c5c3_Err | 701 | return templ_7745c5c3_Err | ||
| 694 | } | 702 | } | ||
| 695 | var templ_7745c5c3_Var35 string | 703 | var templ_7745c5c3_Var35 string | ||
| 696 | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.When) | 704 | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.When) | ||
| 697 | if templ_7745c5c3_Err != nil { | 705 | if templ_7745c5c3_Err != nil { | ||
| 698 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 116, Col: 90} | 706 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 117, Col: 90} |
| 699 | } | 707 | } | ||
| 700 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) | 708 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) | ||
| 701 | if templ_7745c5c3_Err != nil { | 709 | if templ_7745c5c3_Err != nil { | ||
| 702 | return templ_7745c5c3_Err | 710 | return templ_7745c5c3_Err | ||
| 703 | } | 711 | } | ||
| 704 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</time></td></tr>") | 712 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</time></td></tr>") |
| 705 | if templ_7745c5c3_Err != nil { | 713 | if templ_7745c5c3_Err != nil { | ||
| 706 | return templ_7745c5c3_Err | 714 | return templ_7745c5c3_Err | ||
| 707 | } | 715 | } | ||
| 708 | } | 716 | } | ||
| 709 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</tbody></table>") | 717 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</tbody></table>") |
| 710 | if templ_7745c5c3_Err != nil { | 718 | if templ_7745c5c3_Err != nil { | ||
| 711 | return templ_7745c5c3_Err | 719 | return templ_7745c5c3_Err | ||
| 712 | } | 720 | } | ||
M internal/adapters/web/diff.templ +4 -0
| @@ -40,6 +40,10 @@ templ Commit(l Layout, p CommitPage) { | |||
| 40 | 40 | { " · diff against the first parent" } | |
| 41 | 41 | } | |
| 42 | 42 | </dd> | |
| 43 | + | if p.Checks.Total > 0 { | |
| 44 | + | <dt>checks</dt> | |
| 45 | + | <dd>@CommitChecks(p.Checks)</dd> | |
| 46 | + | } | |
| 43 | 47 | if len(p.Refs) > 0 { | |
| 44 | 48 | <dt>refs</dt> | |
| 45 | 49 | <dd> | |
| @@ -40,6 +40,10 @@ templ Commit(l Layout, p CommitPage) { | |||||
| 40 | { " · diff against the first parent" } | 40 | { " · diff against the first parent" } | ||
| 41 | } | 41 | } | ||
| 42 | </dd> | 42 | </dd> | ||
| 43 | + | if p.Checks.Total > 0 { | |||
| 44 | + | <dt>checks</dt> | |||
| 45 | + | <dd>@CommitChecks(p.Checks)</dd> | |||
| 46 | + | } | |||
| 43 | if len(p.Refs) > 0 { | 47 | if len(p.Refs) > 0 { | ||
| 44 | <dt>refs</dt> | 48 | <dt>refs</dt> | ||
| 45 | <dd> | 49 | <dd> | ||
M internal/adapters/web/diff_templ.go +180 -166 generated file: collapsed
M internal/adapters/web/events.go +9 -1
| @@ -31,6 +31,9 @@ import ( | |||
| 31 | 31 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | |
| 32 | 32 | // GET /-/events?topic=repo:<name>&view=mrs | |
| 33 | 33 | // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch> | |
| 34 | + | // GET /-/events?topic=repo:<name>&view=actions | |
| 35 | + | // GET /-/events?topic=repo:<name>&view=run&n=<run id> | |
| 36 | + | // GET /-/events?topic=repo:<name>&view=job&job=<job id> | |
| 34 | 37 | // | |
| 35 | 38 | // topic is the Watch topic. view says which fragments of a repository the | |
| 36 | 39 | // page shows, so that the server renders only those (summary when absent). | |
| @@ -50,6 +53,11 @@ import ( | |||
| 50 | 53 | // commits-head data: <div id="commits-head">N new commits — reload</div> | |
| 51 | 54 | // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages: | |
| 52 | 55 | // something changed, with the link that reloads the page | |
| 56 | + | // ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or | |
| 57 | + | // <div id="run-body">…</div> (one run) | |
| 58 | + | // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log | |
| 59 | + | // itself is never sent in the stream, the page reads the lines after | |
| 60 | + | // the last it holds from /raw?from= | |
| 53 | 61 | // gap data: gap; events were lost, or what the page shows is gone or | |
| 54 | 62 | // changed: the page fetches itself again (ermiteLive in ermite.js) | |
| 55 | 63 | // | |
| @@ -194,7 +202,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 194 | 202 | return | |
| 195 | 203 | } | |
| 196 | 204 | ||
| 197 | - | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")} | |
| 205 | + | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job")} | |
| 198 | 206 | handle := func(m recvd) bool { | |
| 199 | 207 | if m.err != nil { | |
| 200 | 208 | if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) { | |
| @@ -31,6 +31,9 @@ import ( | |||||
| 31 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | 31 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | ||
| 32 | // GET /-/events?topic=repo:<name>&view=mrs | 32 | // GET /-/events?topic=repo:<name>&view=mrs | ||
| 33 | // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch> | 33 | // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch> | ||
| 34 | + | // GET /-/events?topic=repo:<name>&view=actions | |||
| 35 | + | // GET /-/events?topic=repo:<name>&view=run&n=<run id> | |||
| 36 | + | // GET /-/events?topic=repo:<name>&view=job&job=<job id> | |||
| 34 | // | 37 | // | ||
| 35 | // topic is the Watch topic. view says which fragments of a repository the | 38 | // topic is the Watch topic. view says which fragments of a repository the | ||
| 36 | // page shows, so that the server renders only those (summary when absent). | 39 | // page shows, so that the server renders only those (summary when absent). | ||
| @@ -50,6 +53,11 @@ import ( | |||||
| 50 | // commits-head data: <div id="commits-head">N new commits — reload</div> | 53 | // commits-head data: <div id="commits-head">N new commits — reload</div> | ||
| 51 | // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages: | 54 | // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages: | ||
| 52 | // something changed, with the link that reloads the page | 55 | // something changed, with the link that reloads the page | ||
| 56 | + | // ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or | |||
| 57 | + | // <div id="run-body">…</div> (one run) | |||
| 58 | + | // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log | |||
| 59 | + | // itself is never sent in the stream, the page reads the lines after | |||
| 60 | + | // the last it holds from /raw?from= | |||
| 53 | // gap data: gap; events were lost, or what the page shows is gone or | 61 | // gap data: gap; events were lost, or what the page shows is gone or | ||
| 54 | // changed: the page fetches itself again (ermiteLive in ermite.js) | 62 | // changed: the page fetches itself again (ermiteLive in ermite.js) | ||
| 55 | // | 63 | // | ||
| @@ -194,7 +202,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 194 | return | 202 | return | ||
| 195 | } | 203 | } | ||
| 196 | 204 | ||||
| 197 | - | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")} | 205 | + | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job")} |
| 198 | handle := func(m recvd) bool { | 206 | handle := func(m recvd) bool { | ||
| 199 | if m.err != nil { | 207 | if m.err != nil { | ||
| 200 | if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) { | 208 | if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) { | ||
M internal/adapters/web/fragments.go +109 -2
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "context" | |
| 8 | 8 | "errors" | |
| 9 | 9 | "strconv" | |
| 10 | + | "strings" | |
| 10 | 11 | "sync" | |
| 11 | 12 | "time" | |
| 12 | 13 | ||
| @@ -23,7 +24,8 @@ type liveStream struct { | |||
| 23 | 24 | view string // "summary", "refs" or "commits" | |
| 24 | 25 | ref string // commits: the branch listed | |
| 25 | 26 | head string // commits: the commit listed first | |
| 26 | - | n string // mr: the number of the merge request shown | |
| 27 | + | n string // mr: the number of the merge request shown; run: the run id | |
| 28 | + | job string // job: the id of the job shown | |
| 27 | 29 | } | |
| 28 | 30 | ||
| 29 | 31 | // renderFragment renders a component to the data of an event. | |
| @@ -46,7 +48,8 @@ func liveEvent(we ports.WatchEvent) bool { | |||
| 46 | 48 | } | |
| 47 | 49 | switch we.Event.(type) { | |
| 48 | 50 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |
| 49 | - | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded: | |
| 51 | + | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded, | |
| 52 | + | domain.RunUpdated, domain.JobUpdated: | |
| 50 | 53 | return true | |
| 51 | 54 | } | |
| 52 | 55 | return false | |
| @@ -117,6 +120,10 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) | |||
| 117 | 120 | // repoPage handles the events of the topic of one repository. | |
| 118 | 121 | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | |
| 119 | 122 | switch ev := we.Event.(type) { | |
| 123 | + | case domain.RunUpdated: | |
| 124 | + | return l.runEvent(ctx, ev) | |
| 125 | + | case domain.JobUpdated: | |
| 126 | + | return l.jobEvent(ctx, ev) | |
| 120 | 127 | case domain.RepoDeleted, domain.RepoCreated: | |
| 121 | 128 | // Gone, or a new repository under the same name: whatever the page | |
| 122 | 129 | // shows is not true any more. | |
| @@ -395,3 +402,103 @@ func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, | |||
| 395 | 402 | f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href)) | |
| 396 | 403 | return []fragment{f}, err | |
| 397 | 404 | } | |
| 405 | + | ||
| 406 | + | // runEvent updates the pages of CI that a RunUpdated changes: the row of the | |
| 407 | + | // list of runs, or the body of the run page shown. Only a state is sent: the | |
| 408 | + | // events carry no log and no text of the workflow. | |
| 409 | + | func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) { | |
| 410 | + | if l.s.cfg.Runs == nil { | |
| 411 | + | return nil, nil | |
| 412 | + | } | |
| 413 | + | switch l.view { | |
| 414 | + | case "actions": | |
| 415 | + | f, err := renderFragment(ctx, "ci-run", RunListRow(l.s.runRow(l.repo, ev.Run, l.s.cfg.Now()))) | |
| 416 | + | if err != nil { | |
| 417 | + | return nil, err | |
| 418 | + | } | |
| 419 | + | if id, ok := rowIsNew(ev.Run); ok { | |
| 420 | + | f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1) | |
| 421 | + | _ = id | |
| 422 | + | } | |
| 423 | + | return []fragment{f}, nil | |
| 424 | + | case "run": | |
| 425 | + | if strconv.FormatInt(ev.Run.ID, 10) != l.n { | |
| 426 | + | return nil, nil | |
| 427 | + | } | |
| 428 | + | return l.runBody(ctx) | |
| 429 | + | } | |
| 430 | + | return nil, nil | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | // rowIsNew is true for a run created a moment ago: the list adds its row. | |
| 434 | + | func rowIsNew(run domain.CIRun) (int64, bool) { return run.ID, run.Status == domain.CIQueued } | |
| 435 | + | ||
| 436 | + | // jobEvent updates the body of the run page and the steps of the job page. | |
| 437 | + | func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) { | |
| 438 | + | if l.s.cfg.Runs == nil { | |
| 439 | + | return nil, nil | |
| 440 | + | } | |
| 441 | + | switch l.view { | |
| 442 | + | case "run": | |
| 443 | + | if strconv.FormatInt(ev.Job.RunID, 10) != l.n { | |
| 444 | + | return nil, nil | |
| 445 | + | } | |
| 446 | + | return l.runBody(ctx) | |
| 447 | + | case "job": | |
| 448 | + | if ev.Job.ID != l.job { | |
| 449 | + | return nil, nil | |
| 450 | + | } | |
| 451 | + | return l.jobState(ctx) | |
| 452 | + | } | |
| 453 | + | return nil, nil | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | // runBody renders again the jobs of the run page. | |
| 457 | + | func (l *liveStream) runBody(ctx context.Context) ([]fragment, error) { | |
| 458 | + | id, err := strconv.ParseInt(l.n, 10, 64) | |
| 459 | + | if err != nil { | |
| 460 | + | return nil, nil | |
| 461 | + | } | |
| 462 | + | d, err := l.s.loadRepo(ctx, l.repo) | |
| 463 | + | if err != nil { | |
| 464 | + | return nil, err | |
| 465 | + | } | |
| 466 | + | p, err := l.s.runPage(ctx, d, id, 0) | |
| 467 | + | if err != nil { | |
| 468 | + | return nil, err | |
| 469 | + | } | |
| 470 | + | body, err := renderFragment(ctx, "ci-run", runBodyFragment(p)) | |
| 471 | + | if err != nil { | |
| 472 | + | return nil, err | |
| 473 | + | } | |
| 474 | + | // The buttons depend on the state: the page fetches itself again when the | |
| 475 | + | // run completed, so that the owner gets the right ones. | |
| 476 | + | if !p.Active { | |
| 477 | + | return []fragment{body, {event: "gap", html: "gap"}}, nil | |
| 478 | + | } | |
| 479 | + | return []fragment{body}, nil | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | // jobState tells the job page that its state or its steps changed: the page | |
| 483 | + | // reloads the part of the log after the last line it holds (ermite.js). | |
| 484 | + | func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) { | |
| 485 | + | d, err := l.s.loadRepo(ctx, l.repo) | |
| 486 | + | if err != nil { | |
| 487 | + | return nil, err | |
| 488 | + | } | |
| 489 | + | view, err := l.s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, l.job, 0) | |
| 490 | + | if err != nil { | |
| 491 | + | return nil, err | |
| 492 | + | } | |
| 493 | + | now := l.s.cfg.Now() | |
| 494 | + | row := l.s.jobRow(d.name(), view, now) | |
| 495 | + | f, err := renderFragment(ctx, "ci-job", JobHead(row, view.Lines)) | |
| 496 | + | if err != nil { | |
| 497 | + | return nil, err | |
| 498 | + | } | |
| 499 | + | steps, err := renderFragment(ctx, "ci-job", JobSteps(stepViews(view, now))) | |
| 500 | + | if err != nil { | |
| 501 | + | return nil, err | |
| 502 | + | } | |
| 503 | + | return []fragment{f, steps}, nil | |
| 504 | + | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "context" | 7 | "context" | ||
| 8 | "errors" | 8 | "errors" | ||
| 9 | "strconv" | 9 | "strconv" | ||
| 10 | + | "strings" | |||
| 10 | "sync" | 11 | "sync" | ||
| 11 | "time" | 12 | "time" | ||
| 12 | 13 | ||||
| @@ -23,7 +24,8 @@ type liveStream struct { | |||||
| 23 | view string // "summary", "refs" or "commits" | 24 | view string // "summary", "refs" or "commits" | ||
| 24 | ref string // commits: the branch listed | 25 | ref string // commits: the branch listed | ||
| 25 | head string // commits: the commit listed first | 26 | head string // commits: the commit listed first | ||
| 26 | - | n string // mr: the number of the merge request shown | 27 | + | n string // mr: the number of the merge request shown; run: the run id |
| 28 | + | job string // job: the id of the job shown | |||
| 27 | } | 29 | } | ||
| 28 | 30 | ||||
| 29 | // renderFragment renders a component to the data of an event. | 31 | // renderFragment renders a component to the data of an event. | ||
| @@ -46,7 +48,8 @@ func liveEvent(we ports.WatchEvent) bool { | |||||
| 46 | } | 48 | } | ||
| 47 | switch we.Event.(type) { | 49 | switch we.Event.(type) { | ||
| 48 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | 50 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | ||
| 49 | - | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded: | 51 | + | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded, |
| 52 | + | domain.RunUpdated, domain.JobUpdated: | |||
| 50 | return true | 53 | return true | ||
| 51 | } | 54 | } | ||
| 52 | return false | 55 | return false | ||
| @@ -117,6 +120,10 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) | |||||
| 117 | // repoPage handles the events of the topic of one repository. | 120 | // repoPage handles the events of the topic of one repository. | ||
| 118 | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | 121 | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | ||
| 119 | switch ev := we.Event.(type) { | 122 | switch ev := we.Event.(type) { | ||
| 123 | + | case domain.RunUpdated: | |||
| 124 | + | return l.runEvent(ctx, ev) | |||
| 125 | + | case domain.JobUpdated: | |||
| 126 | + | return l.jobEvent(ctx, ev) | |||
| 120 | case domain.RepoDeleted, domain.RepoCreated: | 127 | case domain.RepoDeleted, domain.RepoCreated: | ||
| 121 | // Gone, or a new repository under the same name: whatever the page | 128 | // Gone, or a new repository under the same name: whatever the page | ||
| 122 | // shows is not true any more. | 129 | // shows is not true any more. | ||
| @@ -395,3 +402,103 @@ func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, | |||||
| 395 | f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href)) | 402 | f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href)) | ||
| 396 | return []fragment{f}, err | 403 | return []fragment{f}, err | ||
| 397 | } | 404 | } | ||
| 405 | + | ||||
| 406 | + | // runEvent updates the pages of CI that a RunUpdated changes: the row of the | |||
| 407 | + | // list of runs, or the body of the run page shown. Only a state is sent: the | |||
| 408 | + | // events carry no log and no text of the workflow. | |||
| 409 | + | func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) { | |||
| 410 | + | if l.s.cfg.Runs == nil { | |||
| 411 | + | return nil, nil | |||
| 412 | + | } | |||
| 413 | + | switch l.view { | |||
| 414 | + | case "actions": | |||
| 415 | + | f, err := renderFragment(ctx, "ci-run", RunListRow(l.s.runRow(l.repo, ev.Run, l.s.cfg.Now()))) | |||
| 416 | + | if err != nil { | |||
| 417 | + | return nil, err | |||
| 418 | + | } | |||
| 419 | + | if id, ok := rowIsNew(ev.Run); ok { | |||
| 420 | + | f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1) | |||
| 421 | + | _ = id | |||
| 422 | + | } | |||
| 423 | + | return []fragment{f}, nil | |||
| 424 | + | case "run": | |||
| 425 | + | if strconv.FormatInt(ev.Run.ID, 10) != l.n { | |||
| 426 | + | return nil, nil | |||
| 427 | + | } | |||
| 428 | + | return l.runBody(ctx) | |||
| 429 | + | } | |||
| 430 | + | return nil, nil | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | // rowIsNew is true for a run created a moment ago: the list adds its row. | |||
| 434 | + | func rowIsNew(run domain.CIRun) (int64, bool) { return run.ID, run.Status == domain.CIQueued } | |||
| 435 | + | ||||
| 436 | + | // jobEvent updates the body of the run page and the steps of the job page. | |||
| 437 | + | func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) { | |||
| 438 | + | if l.s.cfg.Runs == nil { | |||
| 439 | + | return nil, nil | |||
| 440 | + | } | |||
| 441 | + | switch l.view { | |||
| 442 | + | case "run": | |||
| 443 | + | if strconv.FormatInt(ev.Job.RunID, 10) != l.n { | |||
| 444 | + | return nil, nil | |||
| 445 | + | } | |||
| 446 | + | return l.runBody(ctx) | |||
| 447 | + | case "job": | |||
| 448 | + | if ev.Job.ID != l.job { | |||
| 449 | + | return nil, nil | |||
| 450 | + | } | |||
| 451 | + | return l.jobState(ctx) | |||
| 452 | + | } | |||
| 453 | + | return nil, nil | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | // runBody renders again the jobs of the run page. | |||
| 457 | + | func (l *liveStream) runBody(ctx context.Context) ([]fragment, error) { | |||
| 458 | + | id, err := strconv.ParseInt(l.n, 10, 64) | |||
| 459 | + | if err != nil { | |||
| 460 | + | return nil, nil | |||
| 461 | + | } | |||
| 462 | + | d, err := l.s.loadRepo(ctx, l.repo) | |||
| 463 | + | if err != nil { | |||
| 464 | + | return nil, err | |||
| 465 | + | } | |||
| 466 | + | p, err := l.s.runPage(ctx, d, id, 0) | |||
| 467 | + | if err != nil { | |||
| 468 | + | return nil, err | |||
| 469 | + | } | |||
| 470 | + | body, err := renderFragment(ctx, "ci-run", runBodyFragment(p)) | |||
| 471 | + | if err != nil { | |||
| 472 | + | return nil, err | |||
| 473 | + | } | |||
| 474 | + | // The buttons depend on the state: the page fetches itself again when the | |||
| 475 | + | // run completed, so that the owner gets the right ones. | |||
| 476 | + | if !p.Active { | |||
| 477 | + | return []fragment{body, {event: "gap", html: "gap"}}, nil | |||
| 478 | + | } | |||
| 479 | + | return []fragment{body}, nil | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | // jobState tells the job page that its state or its steps changed: the page | |||
| 483 | + | // reloads the part of the log after the last line it holds (ermite.js). | |||
| 484 | + | func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) { | |||
| 485 | + | d, err := l.s.loadRepo(ctx, l.repo) | |||
| 486 | + | if err != nil { | |||
| 487 | + | return nil, err | |||
| 488 | + | } | |||
| 489 | + | view, err := l.s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, l.job, 0) | |||
| 490 | + | if err != nil { | |||
| 491 | + | return nil, err | |||
| 492 | + | } | |||
| 493 | + | now := l.s.cfg.Now() | |||
| 494 | + | row := l.s.jobRow(d.name(), view, now) | |||
| 495 | + | f, err := renderFragment(ctx, "ci-job", JobHead(row, view.Lines)) | |||
| 496 | + | if err != nil { | |||
| 497 | + | return nil, err | |||
| 498 | + | } | |||
| 499 | + | steps, err := renderFragment(ctx, "ci-job", JobSteps(stepViews(view, now))) | |||
| 500 | + | if err != nil { | |||
| 501 | + | return nil, err | |||
| 502 | + | } | |||
| 503 | + | return []fragment{f, steps}, nil | |||
| 504 | + | } | |||
M internal/adapters/web/fragments.templ +1 -1
| @@ -101,7 +101,7 @@ templ Live(l Layout) { | |||
| 101 | 101 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 102 | 102 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 103 | 103 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 104 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 104 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 105 | 105 | gap -> ermiteLive.reload()" | |
| 106 | 106 | ></span> | |
| 107 | 107 | } | |
| @@ -101,7 +101,7 @@ templ Live(l Layout) { | |||||
| 101 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | 101 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | ||
| 102 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 102 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 103 | htmx:sse:before:connection -> ermiteLive.gate(event); | 103 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 104 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | 104 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); |
| 105 | gap -> ermiteLive.reload()" | 105 | gap -> ermiteLive.reload()" | ||
| 106 | ></span> | 106 | ></span> | ||
| 107 | } | 107 | } | ||
M internal/adapters/web/fragments_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr.go +8 -0
| @@ -205,6 +205,8 @@ type MRPage struct { | |||
| 205 | 205 | CommitsNote string | |
| 206 | 206 | Modes []string | |
| 207 | 207 | ChecksBlock bool | |
| 208 | + | // Checks are the CI checks of the head of the request. | |
| 209 | + | Checks ChecksSection | |
| 208 | 210 | ||
| 209 | 211 | Box *MergeBox | |
| 210 | 212 | // Live is true when the page announces changes. | |
| @@ -456,6 +458,12 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 456 | 458 | } | |
| 457 | 459 | } | |
| 458 | 460 | p.ChecksBlock = d.repo.Settings.FailingChecksBlock | |
| 461 | + | if open && head != "" { | |
| 462 | + | p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head), now) | |
| 463 | + | } else if head != "" { | |
| 464 | + | p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head), now) | |
| 465 | + | p.Checks.Note = "" | |
| 466 | + | } | |
| 459 | 467 | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |
| 460 | 468 | ||
| 461 | 469 | // The commits of the request, newest first. | |
| @@ -205,6 +205,8 @@ type MRPage struct { | |||||
| 205 | CommitsNote string | 205 | CommitsNote string | ||
| 206 | Modes []string | 206 | Modes []string | ||
| 207 | ChecksBlock bool | 207 | ChecksBlock bool | ||
| 208 | + | // Checks are the CI checks of the head of the request. | |||
| 209 | + | Checks ChecksSection | |||
| 208 | 210 | ||||
| 209 | Box *MergeBox | 211 | Box *MergeBox | ||
| 210 | // Live is true when the page announces changes. | 212 | // Live is true when the page announces changes. | ||
| @@ -456,6 +458,12 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 456 | } | 458 | } | ||
| 457 | } | 459 | } | ||
| 458 | p.ChecksBlock = d.repo.Settings.FailingChecksBlock | 460 | p.ChecksBlock = d.repo.Settings.FailingChecksBlock | ||
| 461 | + | if open && head != "" { | |||
| 462 | + | p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head), now) | |||
| 463 | + | } else if head != "" { | |||
| 464 | + | p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head), now) | |||
| 465 | + | p.Checks.Note = "" | |||
| 466 | + | } | |||
| 459 | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | 467 | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | ||
| 460 | 468 | ||||
| 461 | // The commits of the request, newest first. | 469 | // The commits of the request, newest first. | ||
M internal/adapters/web/mr.templ +1 -5
| @@ -302,11 +302,7 @@ templ mrRail(p MRPage) { | |||
| 302 | 302 | if p.Box != nil { | |
| 303 | 303 | @mergeBox(p) | |
| 304 | 304 | } | |
| 305 | - | <section aria-labelledby="r-checks"> | |
| 306 | - | <h2 id="r-checks">Checks <span class="count">0</span></h2> | |
| 307 | - | <p class="dim">no checks</p> | |
| 308 | - | <p class="mute">there is no CI yet: nothing blocks the merge.</p> | |
| 309 | - | </section> | |
| 305 | + | @MRChecks(p.Checks) | |
| 310 | 306 | <section aria-labelledby="r-settings"> | |
| 311 | 307 | <h2 id="r-settings">Settings</h2> | |
| 312 | 308 | <dl class="kv"> | |
| @@ -302,11 +302,7 @@ templ mrRail(p MRPage) { | |||||
| 302 | if p.Box != nil { | 302 | if p.Box != nil { | ||
| 303 | @mergeBox(p) | 303 | @mergeBox(p) | ||
| 304 | } | 304 | } | ||
| 305 | - | <section aria-labelledby="r-checks"> | 305 | + | @MRChecks(p.Checks) |
| 306 | - | <h2 id="r-checks">Checks <span class="count">0</span></h2> | |||
| 307 | - | <p class="dim">no checks</p> | |||
| 308 | - | <p class="mute">there is no CI yet: nothing blocks the merge.</p> | |||
| 309 | - | </section> | |||
| 310 | <section aria-labelledby="r-settings"> | 306 | <section aria-labelledby="r-settings"> | ||
| 311 | <h2 id="r-settings">Settings</h2> | 307 | <h2 id="r-settings">Settings</h2> | ||
| 312 | <dl class="kv"> | 308 | <dl class="kv"> | ||
M internal/adapters/web/mr_templ.go +56 -52 generated file: collapsed
M internal/adapters/web/pages.templ +1 -0
| @@ -128,6 +128,7 @@ templ refRow(kind string, r RefRow) { | |||
| 128 | 128 | } else { | |
| 129 | 129 | { r.Short } | |
| 130 | 130 | } | |
| 131 | + | @CommitChecks(r.Checks) | |
| 131 | 132 | </td> | |
| 132 | 133 | <td class="c-sub">{ r.Subject }</td> | |
| 133 | 134 | <td class="c-when dim"> | |
| @@ -128,6 +128,7 @@ templ refRow(kind string, r RefRow) { | |||||
| 128 | } else { | 128 | } else { | ||
| 129 | { r.Short } | 129 | { r.Short } | ||
| 130 | } | 130 | } | ||
| 131 | + | @CommitChecks(r.Checks) | |||
| 131 | </td> | 132 | </td> | ||
| 132 | <td class="c-sub">{ r.Subject }</td> | 133 | <td class="c-sub">{ r.Subject }</td> | ||
| 133 | <td class="c-when dim"> | 134 | <td class="c-when dim"> | ||
M internal/adapters/web/pages_templ.go +17 -13 generated file: collapsed
M internal/adapters/web/refspage.go +4 -3
| @@ -43,14 +43,14 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | |||
| 43 | 43 | return a && !b | |
| 44 | 44 | }) | |
| 45 | 45 | for i, b := range branches { | |
| 46 | - | p.Branches = append(p.Branches, s.refRow(d, b, commits, b.ShortName == def, i == 0)) | |
| 46 | + | p.Branches = append(p.Branches, s.refRow(ctx, d, b, commits, b.ShortName == def, i == 0)) | |
| 47 | 47 | } | |
| 48 | 48 | tags := append([]domain.Ref(nil), d.tags...) | |
| 49 | 49 | sort.SliceStable(tags, func(i, j int) bool { | |
| 50 | 50 | return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at | |
| 51 | 51 | }) | |
| 52 | 52 | for i, t := range tags { | |
| 53 | - | p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0)) | |
| 53 | + | p.Tags = append(p.Tags, s.refRow(ctx, d, t, commits, false, len(p.Branches) == 0 && i == 0)) | |
| 54 | 54 | } | |
| 55 | 55 | if len(tags) > 0 { | |
| 56 | 56 | p.Latest = tags[0].ShortName | |
| @@ -61,7 +61,7 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | |||
| 61 | 61 | return p | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | - | func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | |
| 64 | + | func (s *Server) refRow(ctx context.Context, d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | |
| 65 | 65 | id := r.CommitID.String() | |
| 66 | 66 | c := commits[id] | |
| 67 | 67 | row := RefRow{ | |
| @@ -71,6 +71,7 @@ func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, | |||
| 71 | 71 | } | |
| 72 | 72 | if id != "" { | |
| 73 | 73 | row.CHref = repoHref(d.name(), "commit", id) | |
| 74 | + | row.Checks = checksSummary(d.repo.Name, id, s.checksFor(ctx, d.repo.Name, id)) | |
| 74 | 75 | } | |
| 75 | 76 | return row | |
| 76 | 77 | } | |
| @@ -43,14 +43,14 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | |||||
| 43 | return a && !b | 43 | return a && !b | ||
| 44 | }) | 44 | }) | ||
| 45 | for i, b := range branches { | 45 | for i, b := range branches { | ||
| 46 | - | p.Branches = append(p.Branches, s.refRow(d, b, commits, b.ShortName == def, i == 0)) | 46 | + | p.Branches = append(p.Branches, s.refRow(ctx, d, b, commits, b.ShortName == def, i == 0)) |
| 47 | } | 47 | } | ||
| 48 | tags := append([]domain.Ref(nil), d.tags...) | 48 | tags := append([]domain.Ref(nil), d.tags...) | ||
| 49 | sort.SliceStable(tags, func(i, j int) bool { | 49 | sort.SliceStable(tags, func(i, j int) bool { | ||
| 50 | return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at | 50 | return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at | ||
| 51 | }) | 51 | }) | ||
| 52 | for i, t := range tags { | 52 | for i, t := range tags { | ||
| 53 | - | p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0)) | 53 | + | p.Tags = append(p.Tags, s.refRow(ctx, d, t, commits, false, len(p.Branches) == 0 && i == 0)) |
| 54 | } | 54 | } | ||
| 55 | if len(tags) > 0 { | 55 | if len(tags) > 0 { | ||
| 56 | p.Latest = tags[0].ShortName | 56 | p.Latest = tags[0].ShortName | ||
| @@ -61,7 +61,7 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | |||||
| 61 | return p | 61 | return p | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | - | func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | 64 | + | func (s *Server) refRow(ctx context.Context, d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { |
| 65 | id := r.CommitID.String() | 65 | id := r.CommitID.String() | ||
| 66 | c := commits[id] | 66 | c := commits[id] | ||
| 67 | row := RefRow{ | 67 | row := RefRow{ | ||
| @@ -71,6 +71,7 @@ func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, | |||||
| 71 | } | 71 | } | ||
| 72 | if id != "" { | 72 | if id != "" { | ||
| 73 | row.CHref = repoHref(d.name(), "commit", id) | 73 | row.CHref = repoHref(d.name(), "commit", id) | ||
| 74 | + | row.Checks = checksSummary(d.repo.Name, id, s.checksFor(ctx, d.repo.Name, id)) | |||
| 74 | } | 75 | } | ||
| 75 | return row | 76 | return row | ||
| 76 | } | 77 | } | ||
M internal/adapters/web/repo.go +9 -0
| @@ -135,6 +135,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||
| 135 | 135 | if s.cfg.MergeRequests != nil { | |
| 136 | 136 | l.Sections = append(l.Sections, mk("m", "Merge requests", mrBase(d.name()), "merge-requests")) | |
| 137 | 137 | } | |
| 138 | + | l.Sections = s.withActions(l.Sections, d.name(), section) | |
| 138 | 139 | l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}} | |
| 139 | 140 | ||
| 140 | 141 | l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"}) | |
| @@ -161,6 +162,14 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||
| 161 | 162 | return l | |
| 162 | 163 | } | |
| 163 | 164 | ||
| 165 | + | // withActions adds the Actions tab when the server runs CI. | |
| 166 | + | func (s *Server) withActions(sections []Section, repo, current string) []Section { | |
| 167 | + | if s.cfg.Runs == nil { | |
| 168 | + | return sections | |
| 169 | + | } | |
| 170 | + | return append(sections, Section{Key: "a", Label: "Actions", Href: actionsBase(repo), Current: current == "actions"}) | |
| 171 | + | } | |
| 172 | + | ||
| 164 | 173 | // cloneURL is the HTTPS clone URL of a repository. | |
| 165 | 174 | func (s *Server) cloneURL(name string) string { | |
| 166 | 175 | return s.cfg.PublicURL + "/" + name + ".git" | |
| @@ -135,6 +135,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||||
| 135 | if s.cfg.MergeRequests != nil { | 135 | if s.cfg.MergeRequests != nil { | ||
| 136 | l.Sections = append(l.Sections, mk("m", "Merge requests", mrBase(d.name()), "merge-requests")) | 136 | l.Sections = append(l.Sections, mk("m", "Merge requests", mrBase(d.name()), "merge-requests")) | ||
| 137 | } | 137 | } | ||
| 138 | + | l.Sections = s.withActions(l.Sections, d.name(), section) | |||
| 138 | l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}} | 139 | l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}} | ||
| 139 | 140 | ||||
| 140 | l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"}) | 141 | l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"}) | ||
| @@ -161,6 +162,14 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||||
| 161 | return l | 162 | return l | ||
| 162 | } | 163 | } | ||
| 163 | 164 | ||||
| 165 | + | // withActions adds the Actions tab when the server runs CI. | |||
| 166 | + | func (s *Server) withActions(sections []Section, repo, current string) []Section { | |||
| 167 | + | if s.cfg.Runs == nil { | |||
| 168 | + | return sections | |||
| 169 | + | } | |||
| 170 | + | return append(sections, Section{Key: "a", Label: "Actions", Href: actionsBase(repo), Current: current == "actions"}) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 164 | // cloneURL is the HTTPS clone URL of a repository. | 173 | // cloneURL is the HTTPS clone URL of a repository. | ||
| 165 | func (s *Server) cloneURL(name string) string { | 174 | func (s *Server) cloneURL(name string) string { | ||
| 166 | return s.cfg.PublicURL + "/" + name + ".git" | 175 | return s.cfg.PublicURL + "/" + name + ".git" | ||
A internal/adapters/web/static/css/65-ci.css +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | @layer components { | |
| 2 | + | /* CI: runs, jobs, steps, checks and the log. Hue only where it carries a | |
| 3 | + | verdict, and every mark has its word beside it or in its label. */ | |
| 4 | + | .ck { | |
| 5 | + | display: inline-block; width: 14px; height: 14px; margin-right: var(--s1); vertical-align: -2px; | |
| 6 | + | border: 2px solid var(--ink-dim); border-radius: 50%; box-sizing: border-box; flex: none; | |
| 7 | + | } | |
| 8 | + | .ck-ok { background: var(--teal); border-color: var(--teal); } | |
| 9 | + | .ck-bad { background: var(--red); border-color: var(--red); border-radius: 3px; transform: rotate(45deg) scale(.85); } | |
| 10 | + | .ck-cancelled { border-style: dashed; } | |
| 11 | + | .ck-skipped { border-color: var(--border-strong); background: var(--bg-raised); } | |
| 12 | + | .ck-queued { border-style: dotted; } | |
| 13 | + | .ck-running { border-color: var(--teal); border-top-color: transparent; animation: ck-spin 1s linear infinite; } | |
| 14 | + | @keyframes ck-spin { to { transform: rotate(360deg); } } | |
| 15 | + | @media (prefers-reduced-motion: reduce) { .ck-running { animation: none; border-top-color: var(--teal); } } | |
| 16 | + | ||
| 17 | + | .ck-sum { display: inline-flex; align-items: center; gap: var(--s1); margin-left: var(--s2); font-size: var(--fs-xs); white-space: nowrap; } | |
| 18 | + | .c-ck { width: 1%; white-space: nowrap; } | |
| 19 | + | ||
| 20 | + | .runs .c-st { width: 1%; } | |
| 21 | + | .runs .c-dur, .runs .c-who { white-space: nowrap; } | |
| 22 | + | .runs tr[data-new] { background: var(--bg-raised); } | |
| 23 | + | ||
| 24 | + | .run-h { display: flex; align-items: center; gap: var(--s2); margin: 0 0 var(--s4); } | |
| 25 | + | .run-err, .job-err { | |
| 26 | + | max-width: var(--measure); padding: var(--s3) var(--s4); margin: 0 0 var(--s4); white-space: pre-wrap; overflow-wrap: anywhere; | |
| 27 | + | background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--red); border-radius: var(--r-s); | |
| 28 | + | font: inherit; font-size: var(--fs-s); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | .jobs { display: grid; gap: var(--s4); padding: 0; list-style: none; } | |
| 32 | + | .jobs > li { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); } | |
| 33 | + | .job-h { display: flex; align-items: center; gap: var(--s2); font-weight: 700; } | |
| 34 | + | .steps { display: grid; gap: 2px; padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); } | |
| 35 | + | .steps li { display: flex; align-items: center; gap: var(--s1); } | |
| 36 | + | .attempts { padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); } | |
| 37 | + | .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); } | |
| 38 | + | .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); } | |
| 39 | + | ||
| 40 | + | .joblog { margin-top: var(--s4); } | |
| 41 | + | .log { | |
| 42 | + | margin: 0; padding: var(--s2) 0; list-style: none; overflow-x: auto; | |
| 43 | + | font: var(--fs-s) / 1.5 var(--mono); background: var(--bg-inset); border: 1px solid var(--border); border-radius: var(--r-s); | |
| 44 | + | } | |
| 45 | + | .log .lg { display: flex; gap: var(--s3); padding: 0 var(--s3); white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 46 | + | .log .lg:target { background: var(--bg-raised); } | |
| 47 | + | .log .lg time { flex: none; color: var(--ink-dim); user-select: none; } | |
| 48 | + | .log .lg .t { min-width: 0; } | |
| 49 | + | .log .group .t { font-weight: 700; } | |
| 50 | + | .log .endgroup { display: none; } | |
| 51 | + | .log .error .t { color: var(--red); } | |
| 52 | + | .joblog .more { margin: var(--s2) 0; font-size: var(--fs-s); } | |
| 53 | + | } | |
| @@ -0,0 +1,53 @@ | |||||
| 1 | + | @layer components { | |||
| 2 | + | /* CI: runs, jobs, steps, checks and the log. Hue only where it carries a | |||
| 3 | + | verdict, and every mark has its word beside it or in its label. */ | |||
| 4 | + | .ck { | |||
| 5 | + | display: inline-block; width: 14px; height: 14px; margin-right: var(--s1); vertical-align: -2px; | |||
| 6 | + | border: 2px solid var(--ink-dim); border-radius: 50%; box-sizing: border-box; flex: none; | |||
| 7 | + | } | |||
| 8 | + | .ck-ok { background: var(--teal); border-color: var(--teal); } | |||
| 9 | + | .ck-bad { background: var(--red); border-color: var(--red); border-radius: 3px; transform: rotate(45deg) scale(.85); } | |||
| 10 | + | .ck-cancelled { border-style: dashed; } | |||
| 11 | + | .ck-skipped { border-color: var(--border-strong); background: var(--bg-raised); } | |||
| 12 | + | .ck-queued { border-style: dotted; } | |||
| 13 | + | .ck-running { border-color: var(--teal); border-top-color: transparent; animation: ck-spin 1s linear infinite; } | |||
| 14 | + | @keyframes ck-spin { to { transform: rotate(360deg); } } | |||
| 15 | + | @media (prefers-reduced-motion: reduce) { .ck-running { animation: none; border-top-color: var(--teal); } } | |||
| 16 | + | ||||
| 17 | + | .ck-sum { display: inline-flex; align-items: center; gap: var(--s1); margin-left: var(--s2); font-size: var(--fs-xs); white-space: nowrap; } | |||
| 18 | + | .c-ck { width: 1%; white-space: nowrap; } | |||
| 19 | + | ||||
| 20 | + | .runs .c-st { width: 1%; } | |||
| 21 | + | .runs .c-dur, .runs .c-who { white-space: nowrap; } | |||
| 22 | + | .runs tr[data-new] { background: var(--bg-raised); } | |||
| 23 | + | ||||
| 24 | + | .run-h { display: flex; align-items: center; gap: var(--s2); margin: 0 0 var(--s4); } | |||
| 25 | + | .run-err, .job-err { | |||
| 26 | + | max-width: var(--measure); padding: var(--s3) var(--s4); margin: 0 0 var(--s4); white-space: pre-wrap; overflow-wrap: anywhere; | |||
| 27 | + | background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--red); border-radius: var(--r-s); | |||
| 28 | + | font: inherit; font-size: var(--fs-s); | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | .jobs { display: grid; gap: var(--s4); padding: 0; list-style: none; } | |||
| 32 | + | .jobs > li { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); } | |||
| 33 | + | .job-h { display: flex; align-items: center; gap: var(--s2); font-weight: 700; } | |||
| 34 | + | .steps { display: grid; gap: 2px; padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); } | |||
| 35 | + | .steps li { display: flex; align-items: center; gap: var(--s1); } | |||
| 36 | + | .attempts { padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); } | |||
| 37 | + | .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); } | |||
| 38 | + | .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); } | |||
| 39 | + | ||||
| 40 | + | .joblog { margin-top: var(--s4); } | |||
| 41 | + | .log { | |||
| 42 | + | margin: 0; padding: var(--s2) 0; list-style: none; overflow-x: auto; | |||
| 43 | + | font: var(--fs-s) / 1.5 var(--mono); background: var(--bg-inset); border: 1px solid var(--border); border-radius: var(--r-s); | |||
| 44 | + | } | |||
| 45 | + | .log .lg { display: flex; gap: var(--s3); padding: 0 var(--s3); white-space: pre-wrap; overflow-wrap: anywhere; } | |||
| 46 | + | .log .lg:target { background: var(--bg-raised); } | |||
| 47 | + | .log .lg time { flex: none; color: var(--ink-dim); user-select: none; } | |||
| 48 | + | .log .lg .t { min-width: 0; } | |||
| 49 | + | .log .group .t { font-weight: 700; } | |||
| 50 | + | .log .endgroup { display: none; } | |||
| 51 | + | .log .error .t { color: var(--red); } | |||
| 52 | + | .joblog .more { margin: var(--s2) 0; font-size: var(--fs-s); } | |||
| 53 | + | } | |||
M internal/adapters/web/static/js/ermite.js +23 -0
| @@ -363,3 +363,26 @@ window.ermiteLive = (() => { | |||
| 363 | 363 | }, | |
| 364 | 364 | }; | |
| 365 | 365 | })(); | |
| 366 | + | ||
| 367 | + | // ermiteLog follows the log of a running job on its page: the lines arrive as | |
| 368 | + | // "ci-log" events (internal/adapters/web/actions.go, logStream) and are | |
| 369 | + | // appended to the list. The browser reconnects by itself with Last-Event-ID, | |
| 370 | + | // the number of the next line; "ci-end" closes the feed when the job is over. | |
| 371 | + | // The page scrolls with the log only while the reader is at its end. | |
| 372 | + | (() => { | |
| 373 | + | const box = document.getElementById('job-log'); | |
| 374 | + | if (!box || !box.hasAttribute('data-follow') || !box.dataset.stream || !window.EventSource) return; | |
| 375 | + | const list = box.querySelector('ol.log'); | |
| 376 | + | if (!list) return; | |
| 377 | + | const url = box.dataset.stream + '?from=' + encodeURIComponent(box.dataset.next || '0'); | |
| 378 | + | const es = new EventSource(url); | |
| 379 | + | es.addEventListener('ci-log', (e) => { | |
| 380 | + | const t = document.createElement('template'); | |
| 381 | + | t.innerHTML = e.data; | |
| 382 | + | const atEnd = window.innerHeight + window.scrollY >= document.body.scrollHeight - 80; | |
| 383 | + | list.append(...t.content.children); | |
| 384 | + | if (atEnd) window.scrollTo(0, document.body.scrollHeight); | |
| 385 | + | }); | |
| 386 | + | es.addEventListener('ci-end', () => es.close()); | |
| 387 | + | es.onerror = () => { if (es.readyState === EventSource.CLOSED) es.close(); }; | |
| 388 | + | })(); | |
| @@ -363,3 +363,26 @@ window.ermiteLive = (() => { | |||||
| 363 | }, | 363 | }, | ||
| 364 | }; | 364 | }; | ||
| 365 | })(); | 365 | })(); | ||
| 366 | + | ||||
| 367 | + | // ermiteLog follows the log of a running job on its page: the lines arrive as | |||
| 368 | + | // "ci-log" events (internal/adapters/web/actions.go, logStream) and are | |||
| 369 | + | // appended to the list. The browser reconnects by itself with Last-Event-ID, | |||
| 370 | + | // the number of the next line; "ci-end" closes the feed when the job is over. | |||
| 371 | + | // The page scrolls with the log only while the reader is at its end. | |||
| 372 | + | (() => { | |||
| 373 | + | const box = document.getElementById('job-log'); | |||
| 374 | + | if (!box || !box.hasAttribute('data-follow') || !box.dataset.stream || !window.EventSource) return; | |||
| 375 | + | const list = box.querySelector('ol.log'); | |||
| 376 | + | if (!list) return; | |||
| 377 | + | const url = box.dataset.stream + '?from=' + encodeURIComponent(box.dataset.next || '0'); | |||
| 378 | + | const es = new EventSource(url); | |||
| 379 | + | es.addEventListener('ci-log', (e) => { | |||
| 380 | + | const t = document.createElement('template'); | |||
| 381 | + | t.innerHTML = e.data; | |||
| 382 | + | const atEnd = window.innerHeight + window.scrollY >= document.body.scrollHeight - 80; | |||
| 383 | + | list.append(...t.content.children); | |||
| 384 | + | if (atEnd) window.scrollTo(0, document.body.scrollHeight); | |||
| 385 | + | }); | |||
| 386 | + | es.addEventListener('ci-end', () => es.close()); | |||
| 387 | + | es.onerror = () => { if (es.readyState === EventSource.CLOSED) es.close(); }; | |||
| 388 | + | })(); | |||
M internal/adapters/web/view.go +5 -0
| @@ -135,6 +135,9 @@ type CommitRow struct { | |||
| 135 | 135 | Branches []string | |
| 136 | 136 | Tags []string | |
| 137 | 137 | Cursor bool | |
| 138 | + | // Checks is the combined state of the CI checks of the commit; empty when | |
| 139 | + | // it has none. | |
| 140 | + | Checks ChecksSummary | |
| 138 | 141 | } | |
| 139 | 142 | ||
| 140 | 143 | // SummaryPage is the repository summary. | |
| @@ -162,6 +165,8 @@ type LatestTag struct { | |||
| 162 | 165 | type RefRow struct { | |
| 163 | 166 | Name, Href, Short, CHref, Subject, When, WhenAt string | |
| 164 | 167 | Default, Cursor bool | |
| 168 | + | // Checks is the combined state of the CI checks of the commit. | |
| 169 | + | Checks ChecksSummary | |
| 165 | 170 | } | |
| 166 | 171 | ||
| 167 | 172 | // RefsPage lists the branches and tags of a repository. | |
| @@ -135,6 +135,9 @@ type CommitRow struct { | |||||
| 135 | Branches []string | 135 | Branches []string | ||
| 136 | Tags []string | 136 | Tags []string | ||
| 137 | Cursor bool | 137 | Cursor bool | ||
| 138 | + | // Checks is the combined state of the CI checks of the commit; empty when | |||
| 139 | + | // it has none. | |||
| 140 | + | Checks ChecksSummary | |||
| 138 | } | 141 | } | ||
| 139 | 142 | ||||
| 140 | // SummaryPage is the repository summary. | 143 | // SummaryPage is the repository summary. | ||
| @@ -162,6 +165,8 @@ type LatestTag struct { | |||||
| 162 | type RefRow struct { | 165 | type RefRow struct { | ||
| 163 | Name, Href, Short, CHref, Subject, When, WhenAt string | 166 | Name, Href, Short, CHref, Subject, When, WhenAt string | ||
| 164 | Default, Cursor bool | 167 | Default, Cursor bool | ||
| 168 | + | // Checks is the combined state of the CI checks of the commit. | |||
| 169 | + | Checks ChecksSummary | |||
| 165 | } | 170 | } | ||
| 166 | 171 | ||||
| 167 | // RefsPage lists the branches and tags of a repository. | 172 | // RefsPage lists the branches and tags of a repository. | ||
M internal/adapters/web/web.go +15 -0
| @@ -54,6 +54,10 @@ type Config struct { | |||
| 54 | 54 | // there are no such pages, nor section for them. Leave it unset rather | |
| 55 | 55 | // than assigning a typed nil pointer. | |
| 56 | 56 | MergeRequests ports.MergeRequestService | |
| 57 | + | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | |
| 58 | + | // commits and merge requests. Without it there are no such pages. Leave it | |
| 59 | + | // unset rather than assigning a typed nil pointer. | |
| 60 | + | Runs ports.RunService | |
| 57 | 61 | // Events is the source of the event stream behind /-/events. Without | |
| 58 | 62 | // it the pages are not live and the route answers 404. Leave it unset | |
| 59 | 63 | // rather than assigning a typed nil pointer. | |
| @@ -161,6 +165,17 @@ func (s *Server) routes() *http.ServeMux { | |||
| 161 | 165 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose)) | |
| 162 | 166 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead)) | |
| 163 | 167 | } | |
| 168 | + | if s.cfg.Runs != nil { | |
| 169 | + | mux.HandleFunc("GET /{repo}/actions", s.page(s.actions)) | |
| 170 | + | mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions)) | |
| 171 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}", s.page(s.run)) | |
| 172 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}", s.page(s.job)) | |
| 173 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/raw", s.rawLog) | |
| 174 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/stream", s.logStream) | |
| 175 | + | mux.HandleFunc("POST /{repo}/actions/runs/{id}/cancel", s.ciAction(s.runCancel)) | |
| 176 | + | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false))) | |
| 177 | + | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true))) | |
| 178 | + | } | |
| 164 | 179 | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | |
| 165 | 180 | return mux | |
| 166 | 181 | } | |
| @@ -54,6 +54,10 @@ type Config struct { | |||||
| 54 | // there are no such pages, nor section for them. Leave it unset rather | 54 | // there are no such pages, nor section for them. Leave it unset rather | ||
| 55 | // than assigning a typed nil pointer. | 55 | // than assigning a typed nil pointer. | ||
| 56 | MergeRequests ports.MergeRequestService | 56 | MergeRequests ports.MergeRequestService | ||
| 57 | + | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | |||
| 58 | + | // commits and merge requests. Without it there are no such pages. Leave it | |||
| 59 | + | // unset rather than assigning a typed nil pointer. | |||
| 60 | + | Runs ports.RunService | |||
| 57 | // Events is the source of the event stream behind /-/events. Without | 61 | // Events is the source of the event stream behind /-/events. Without | ||
| 58 | // it the pages are not live and the route answers 404. Leave it unset | 62 | // it the pages are not live and the route answers 404. Leave it unset | ||
| 59 | // rather than assigning a typed nil pointer. | 63 | // rather than assigning a typed nil pointer. | ||
| @@ -161,6 +165,17 @@ func (s *Server) routes() *http.ServeMux { | |||||
| 161 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose)) | 165 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose)) | ||
| 162 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead)) | 166 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead)) | ||
| 163 | } | 167 | } | ||
| 168 | + | if s.cfg.Runs != nil { | |||
| 169 | + | mux.HandleFunc("GET /{repo}/actions", s.page(s.actions)) | |||
| 170 | + | mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions)) | |||
| 171 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}", s.page(s.run)) | |||
| 172 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}", s.page(s.job)) | |||
| 173 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/raw", s.rawLog) | |||
| 174 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/stream", s.logStream) | |||
| 175 | + | mux.HandleFunc("POST /{repo}/actions/runs/{id}/cancel", s.ciAction(s.runCancel)) | |||
| 176 | + | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false))) | |||
| 177 | + | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true))) | |||
| 178 | + | } | |||
| 164 | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | 179 | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | ||
| 165 | return mux | 180 | return mux | ||
| 166 | } | 181 | } | ||
A internal/app/ci_web_test.go +174 -0
| @@ -0,0 +1,174 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net/url" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "connectrpc.com/connect" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 15 | + | ||
| 16 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // AC5, AC6: the pages of CI: the list of runs, a run, a job with its log (page, | |
| 21 | + | // raw and stream), the owner's buttons, and the checks on the commit, the | |
| 22 | + | // branch list, the commit list and the merge request. | |
| 23 | + | func TestWebActionsPages(t *testing.T) { | |
| 24 | + | ci := newCIHarness(t, nil) | |
| 25 | + | c := &cliHarness{harness: ci.harness, url: ci.srv.URL} | |
| 26 | + | b := browser{ci.harness} | |
| 27 | + | runner := ci.registerRunner("alpha", "ubuntu-latest") | |
| 28 | + | _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 29 | + | Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}, | |
| 30 | + | })) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | ci.push("main", map[string]string{"README": "x\n"}, "") | |
| 33 | + | sha := ci.push("feat", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "add ci") | |
| 34 | + | ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 }) | |
| 35 | + | ||
| 36 | + | // Queued: the list shows it, with a word for its state. | |
| 37 | + | list := b.get("/proj/actions", nil) | |
| 38 | + | require.Equal(t, 200, list.status, list.body) | |
| 39 | + | assert.Contains(t, list.body, "queued") | |
| 40 | + | assert.Contains(t, list.body, `href="/proj/actions/runs/1"`) | |
| 41 | + | assert.Contains(t, list.body, `aria-label="queued"`) | |
| 42 | + | assert.Contains(t, list.body, "/-/events?topic=repo%3Aproj&view=actions", "the page follows the repository stream") | |
| 43 | + | assert.Contains(t, list.body, ">Actions<", "the tab") | |
| 44 | + | ||
| 45 | + | // The MR page says "waiting for a runner" only when no runner matches. | |
| 46 | + | mr, err := ci.root.mr.CreateMergeRequest(ci.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"})) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | mrPage := b.get("/proj/merge-requests/1", nil) | |
| 49 | + | require.Equal(t, 200, mrPage.status) | |
| 50 | + | assert.Contains(t, mrPage.body, `id="mr-checks"`) | |
| 51 | + | assert.Contains(t, mrPage.body, "build") | |
| 52 | + | assert.Contains(t, mrPage.body, "a failing or unfinished check blocks the merge", "the rule shown is the one that applies") | |
| 53 | + | assert.NotContains(t, mrPage.body, "waiting for a runner", "a runner matches") | |
| 54 | + | _ = mr | |
| 55 | + | ||
| 56 | + | job := runner.await() | |
| 57 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "##[group]Run echo hello", "hello <b>bold</b>", "##[endgroup]") | |
| 58 | + | next := runner.await() | |
| 59 | + | runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "##[error]Process completed with exit code 1.") | |
| 60 | + | ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 61 | + | ||
| 62 | + | run := b.get("/proj/actions/runs/1", nil) | |
| 63 | + | require.Equal(t, 200, run.status, run.body) | |
| 64 | + | assert.Contains(t, run.body, "build") | |
| 65 | + | assert.Contains(t, run.body, "test") | |
| 66 | + | assert.Contains(t, run.body, "failure") | |
| 67 | + | assert.Contains(t, run.body, "on alpha (vm)", "the runner and its isolation are named") | |
| 68 | + | assert.NotContains(t, run.body, `action="/proj/actions/runs/1/rerun"`, "no button for a visitor") | |
| 69 | + | ||
| 70 | + | jobPage := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil) | |
| 71 | + | require.Equal(t, 200, jobPage.status, jobPage.body) | |
| 72 | + | assert.Contains(t, jobPage.body, "Run echo hello") | |
| 73 | + | assert.Contains(t, jobPage.body, "hello <b>bold</b>", "log text is escaped, never markup") | |
| 74 | + | assert.NotContains(t, jobPage.body, "<b>bold</b>") | |
| 75 | + | assert.NotContains(t, jobPage.body, "##[group]", "markers are shown as structure") | |
| 76 | + | assert.Contains(t, jobPage.body, `class="lg group"`) | |
| 77 | + | failed := b.get("/proj/actions/runs/1/job/"+next.GetJobId(), nil) | |
| 78 | + | assert.Contains(t, failed.body, `class="lg error"`) | |
| 79 | + | ||
| 80 | + | raw := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/raw", nil) | |
| 81 | + | require.Equal(t, 200, raw.status) | |
| 82 | + | assert.Equal(t, "text/plain; charset=utf-8", raw.hdr.Get("Content-Type")) | |
| 83 | + | assert.Equal(t, "nosniff", raw.hdr.Get("X-Content-Type-Options")) | |
| 84 | + | assert.Contains(t, raw.hdr.Get("Content-Security-Policy"), "sandbox") | |
| 85 | + | assert.Contains(t, raw.body, "hello <b>bold</b>", "raw is raw text") | |
| 86 | + | assert.Regexp(t, `(?m)^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z ##\[group\]Run echo hello$`, raw.body) | |
| 87 | + | ||
| 88 | + | // The stream of a finished job sends its lines then ends. | |
| 89 | + | stream := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/stream?from=1", nil) | |
| 90 | + | require.Equal(t, 200, stream.status) | |
| 91 | + | assert.Equal(t, "text/event-stream", stream.hdr.Get("Content-Type")) | |
| 92 | + | assert.Contains(t, stream.body, "event: ci-log") | |
| 93 | + | assert.Contains(t, stream.body, "event: ci-end") | |
| 94 | + | assert.NotContains(t, stream.body, "Run echo hello", "from=1 skips the first line") | |
| 95 | + | ||
| 96 | + | // Checks next to the commit, the commits list and the branch list. | |
| 97 | + | commit := b.get("/proj/commit/"+sha, nil) | |
| 98 | + | require.Equal(t, 200, commit.status) | |
| 99 | + | assert.Contains(t, commit.body, "checks failed") | |
| 100 | + | assert.Contains(t, b.get("/proj/commits/feat", nil).body, "checks failed") | |
| 101 | + | assert.Contains(t, b.get("/proj/refs", nil).body, "checks failed") | |
| 102 | + | ||
| 103 | + | // Private repository: nothing to anonymous. | |
| 104 | + | _, err = ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 105 | + | Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PRIVATE, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}, | |
| 106 | + | })) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | for _, p := range []string{"/proj/actions", "/proj/actions/runs/1", "/proj/actions/runs/1/job/" + job.GetJobId(), "/proj/actions/runs/1/job/" + job.GetJobId() + "/raw", "/proj/actions/runs/1/job/" + job.GetJobId() + "/stream"} { | |
| 109 | + | assert.Equal(t, 404, b.get(p, nil).status, p) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // The owner sees the buttons and re-runs the failed job. | |
| 113 | + | token := c.signIn(t) | |
| 114 | + | owner := b.get("/proj/actions/runs/1", as(token)) | |
| 115 | + | require.Equal(t, 200, owner.status) | |
| 116 | + | assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun"`) | |
| 117 | + | assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun-failed"`) | |
| 118 | + | assert.NotContains(t, owner.body, `action="/proj/actions/runs/1/cancel"`) | |
| 119 | + | // A POST needs the owner's session and the origin. | |
| 120 | + | assert.Equal(t, 403, b.do("POST", "/proj/actions/runs/1/rerun-failed", map[string]string{"Origin": publicOrigin}, "").status, "anonymous") | |
| 121 | + | res := b.form("/proj/actions/runs/1/rerun-failed", token, url.Values{}) | |
| 122 | + | require.Equal(t, 303, res.status, res.body) | |
| 123 | + | ci.waitFor("attempt 2 is queued", func() bool { return ci.runs()[0].GetAttempt() == 2 }) | |
| 124 | + | again := b.get("/proj/actions/runs/1?attempt=1", as(token)) | |
| 125 | + | assert.Contains(t, again.body, "attempt 1") | |
| 126 | + | assert.NotContains(t, again.body, `action="/proj/actions/runs/1/rerun"`, "an earlier attempt has no button") | |
| 127 | + | ||
| 128 | + | cancel := b.form("/proj/actions/runs/1/cancel", token, url.Values{}) | |
| 129 | + | require.Equal(t, 303, cancel.status, cancel.body) | |
| 130 | + | ci.waitFor("cancelled", func() bool { | |
| 131 | + | return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED && ci.runs()[0].GetConclusion() == pb.CiConclusion_CI_CONCLUSION_CANCELLED | |
| 132 | + | }) | |
| 133 | + | assert.Equal(t, 409, b.form("/proj/actions/runs/1/cancel", token, url.Values{}).status, "a completed run cannot be cancelled") | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // The live updates of the pages: the run page gets the body of the run when a | |
| 137 | + | // job changes, and the job page its state, never a line of the log. | |
| 138 | + | func TestWebActionsLiveFragments(t *testing.T) { | |
| 139 | + | ci := newCIHarness(t, nil) | |
| 140 | + | c := &cliHarness{harness: ci.harness, url: ci.srv.URL} | |
| 141 | + | runner := ci.registerRunner("alpha", "ubuntu-latest") | |
| 142 | + | _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 143 | + | Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}, | |
| 144 | + | })) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "") | |
| 147 | + | ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 }) | |
| 148 | + | token := c.signIn(t) | |
| 149 | + | ||
| 150 | + | run := ci.sse("topic=repo:proj&view=run&n=1", "") | |
| 151 | + | list := ci.sse("topic=repo:proj&view=actions", "") | |
| 152 | + | job := runner.await() | |
| 153 | + | jobStream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "") | |
| 154 | + | _ = token | |
| 155 | + | ||
| 156 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a secret value in the log") | |
| 157 | + | deadline := time.After(10 * time.Second) | |
| 158 | + | var sawRun, sawList, sawJob bool | |
| 159 | + | for !sawRun || !sawList || !sawJob { | |
| 160 | + | select { | |
| 161 | + | case ev := <-run.events: | |
| 162 | + | sawRun = sawRun || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-body"`)) | |
| 163 | + | assert.NotContains(t, ev.data, "a secret value in the log") | |
| 164 | + | case ev := <-list.events: | |
| 165 | + | sawList = sawList || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-1"`)) | |
| 166 | + | case ev := <-jobStream.events: | |
| 167 | + | sawJob = sawJob || (ev.name == "ci-job" && strings.Contains(ev.data, `id="job-head"`)) | |
| 168 | + | assert.NotContains(t, ev.data, "a secret value in the log", "the stream of states never carries the log") | |
| 169 | + | case <-deadline: | |
| 170 | + | t.Fatalf("fragments: run %v, list %v, job %v", sawRun, sawList, sawJob) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | _ = domain.CISuccess | |
| 174 | + | } | |
| @@ -0,0 +1,174 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "net/url" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "connectrpc.com/connect" | |||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |||
| 15 | + | ||||
| 16 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // AC5, AC6: the pages of CI: the list of runs, a run, a job with its log (page, | |||
| 21 | + | // raw and stream), the owner's buttons, and the checks on the commit, the | |||
| 22 | + | // branch list, the commit list and the merge request. | |||
| 23 | + | func TestWebActionsPages(t *testing.T) { | |||
| 24 | + | ci := newCIHarness(t, nil) | |||
| 25 | + | c := &cliHarness{harness: ci.harness, url: ci.srv.URL} | |||
| 26 | + | b := browser{ci.harness} | |||
| 27 | + | runner := ci.registerRunner("alpha", "ubuntu-latest") | |||
| 28 | + | _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |||
| 29 | + | Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}, | |||
| 30 | + | })) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | ci.push("main", map[string]string{"README": "x\n"}, "") | |||
| 33 | + | sha := ci.push("feat", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "add ci") | |||
| 34 | + | ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 }) | |||
| 35 | + | ||||
| 36 | + | // Queued: the list shows it, with a word for its state. | |||
| 37 | + | list := b.get("/proj/actions", nil) | |||
| 38 | + | require.Equal(t, 200, list.status, list.body) | |||
| 39 | + | assert.Contains(t, list.body, "queued") | |||
| 40 | + | assert.Contains(t, list.body, `href="/proj/actions/runs/1"`) | |||
| 41 | + | assert.Contains(t, list.body, `aria-label="queued"`) | |||
| 42 | + | assert.Contains(t, list.body, "/-/events?topic=repo%3Aproj&view=actions", "the page follows the repository stream") | |||
| 43 | + | assert.Contains(t, list.body, ">Actions<", "the tab") | |||
| 44 | + | ||||
| 45 | + | // The MR page says "waiting for a runner" only when no runner matches. | |||
| 46 | + | mr, err := ci.root.mr.CreateMergeRequest(ci.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"})) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | mrPage := b.get("/proj/merge-requests/1", nil) | |||
| 49 | + | require.Equal(t, 200, mrPage.status) | |||
| 50 | + | assert.Contains(t, mrPage.body, `id="mr-checks"`) | |||
| 51 | + | assert.Contains(t, mrPage.body, "build") | |||
| 52 | + | assert.Contains(t, mrPage.body, "a failing or unfinished check blocks the merge", "the rule shown is the one that applies") | |||
| 53 | + | assert.NotContains(t, mrPage.body, "waiting for a runner", "a runner matches") | |||
| 54 | + | _ = mr | |||
| 55 | + | ||||
| 56 | + | job := runner.await() | |||
| 57 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "##[group]Run echo hello", "hello <b>bold</b>", "##[endgroup]") | |||
| 58 | + | next := runner.await() | |||
| 59 | + | runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "##[error]Process completed with exit code 1.") | |||
| 60 | + | ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |||
| 61 | + | ||||
| 62 | + | run := b.get("/proj/actions/runs/1", nil) | |||
| 63 | + | require.Equal(t, 200, run.status, run.body) | |||
| 64 | + | assert.Contains(t, run.body, "build") | |||
| 65 | + | assert.Contains(t, run.body, "test") | |||
| 66 | + | assert.Contains(t, run.body, "failure") | |||
| 67 | + | assert.Contains(t, run.body, "on alpha (vm)", "the runner and its isolation are named") | |||
| 68 | + | assert.NotContains(t, run.body, `action="/proj/actions/runs/1/rerun"`, "no button for a visitor") | |||
| 69 | + | ||||
| 70 | + | jobPage := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil) | |||
| 71 | + | require.Equal(t, 200, jobPage.status, jobPage.body) | |||
| 72 | + | assert.Contains(t, jobPage.body, "Run echo hello") | |||
| 73 | + | assert.Contains(t, jobPage.body, "hello <b>bold</b>", "log text is escaped, never markup") | |||
| 74 | + | assert.NotContains(t, jobPage.body, "<b>bold</b>") | |||
| 75 | + | assert.NotContains(t, jobPage.body, "##[group]", "markers are shown as structure") | |||
| 76 | + | assert.Contains(t, jobPage.body, `class="lg group"`) | |||
| 77 | + | failed := b.get("/proj/actions/runs/1/job/"+next.GetJobId(), nil) | |||
| 78 | + | assert.Contains(t, failed.body, `class="lg error"`) | |||
| 79 | + | ||||
| 80 | + | raw := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/raw", nil) | |||
| 81 | + | require.Equal(t, 200, raw.status) | |||
| 82 | + | assert.Equal(t, "text/plain; charset=utf-8", raw.hdr.Get("Content-Type")) | |||
| 83 | + | assert.Equal(t, "nosniff", raw.hdr.Get("X-Content-Type-Options")) | |||
| 84 | + | assert.Contains(t, raw.hdr.Get("Content-Security-Policy"), "sandbox") | |||
| 85 | + | assert.Contains(t, raw.body, "hello <b>bold</b>", "raw is raw text") | |||
| 86 | + | assert.Regexp(t, `(?m)^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z ##\[group\]Run echo hello$`, raw.body) | |||
| 87 | + | ||||
| 88 | + | // The stream of a finished job sends its lines then ends. | |||
| 89 | + | stream := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/stream?from=1", nil) | |||
| 90 | + | require.Equal(t, 200, stream.status) | |||
| 91 | + | assert.Equal(t, "text/event-stream", stream.hdr.Get("Content-Type")) | |||
| 92 | + | assert.Contains(t, stream.body, "event: ci-log") | |||
| 93 | + | assert.Contains(t, stream.body, "event: ci-end") | |||
| 94 | + | assert.NotContains(t, stream.body, "Run echo hello", "from=1 skips the first line") | |||
| 95 | + | ||||
| 96 | + | // Checks next to the commit, the commits list and the branch list. | |||
| 97 | + | commit := b.get("/proj/commit/"+sha, nil) | |||
| 98 | + | require.Equal(t, 200, commit.status) | |||
| 99 | + | assert.Contains(t, commit.body, "checks failed") | |||
| 100 | + | assert.Contains(t, b.get("/proj/commits/feat", nil).body, "checks failed") | |||
| 101 | + | assert.Contains(t, b.get("/proj/refs", nil).body, "checks failed") | |||
| 102 | + | ||||
| 103 | + | // Private repository: nothing to anonymous. | |||
| 104 | + | _, err = ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |||
| 105 | + | Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PRIVATE, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}, | |||
| 106 | + | })) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | for _, p := range []string{"/proj/actions", "/proj/actions/runs/1", "/proj/actions/runs/1/job/" + job.GetJobId(), "/proj/actions/runs/1/job/" + job.GetJobId() + "/raw", "/proj/actions/runs/1/job/" + job.GetJobId() + "/stream"} { | |||
| 109 | + | assert.Equal(t, 404, b.get(p, nil).status, p) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // The owner sees the buttons and re-runs the failed job. | |||
| 113 | + | token := c.signIn(t) | |||
| 114 | + | owner := b.get("/proj/actions/runs/1", as(token)) | |||
| 115 | + | require.Equal(t, 200, owner.status) | |||
| 116 | + | assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun"`) | |||
| 117 | + | assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun-failed"`) | |||
| 118 | + | assert.NotContains(t, owner.body, `action="/proj/actions/runs/1/cancel"`) | |||
| 119 | + | // A POST needs the owner's session and the origin. | |||
| 120 | + | assert.Equal(t, 403, b.do("POST", "/proj/actions/runs/1/rerun-failed", map[string]string{"Origin": publicOrigin}, "").status, "anonymous") | |||
| 121 | + | res := b.form("/proj/actions/runs/1/rerun-failed", token, url.Values{}) | |||
| 122 | + | require.Equal(t, 303, res.status, res.body) | |||
| 123 | + | ci.waitFor("attempt 2 is queued", func() bool { return ci.runs()[0].GetAttempt() == 2 }) | |||
| 124 | + | again := b.get("/proj/actions/runs/1?attempt=1", as(token)) | |||
| 125 | + | assert.Contains(t, again.body, "attempt 1") | |||
| 126 | + | assert.NotContains(t, again.body, `action="/proj/actions/runs/1/rerun"`, "an earlier attempt has no button") | |||
| 127 | + | ||||
| 128 | + | cancel := b.form("/proj/actions/runs/1/cancel", token, url.Values{}) | |||
| 129 | + | require.Equal(t, 303, cancel.status, cancel.body) | |||
| 130 | + | ci.waitFor("cancelled", func() bool { | |||
| 131 | + | return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED && ci.runs()[0].GetConclusion() == pb.CiConclusion_CI_CONCLUSION_CANCELLED | |||
| 132 | + | }) | |||
| 133 | + | assert.Equal(t, 409, b.form("/proj/actions/runs/1/cancel", token, url.Values{}).status, "a completed run cannot be cancelled") | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // The live updates of the pages: the run page gets the body of the run when a | |||
| 137 | + | // job changes, and the job page its state, never a line of the log. | |||
| 138 | + | func TestWebActionsLiveFragments(t *testing.T) { | |||
| 139 | + | ci := newCIHarness(t, nil) | |||
| 140 | + | c := &cliHarness{harness: ci.harness, url: ci.srv.URL} | |||
| 141 | + | runner := ci.registerRunner("alpha", "ubuntu-latest") | |||
| 142 | + | _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |||
| 143 | + | Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}, | |||
| 144 | + | })) | |||
| 145 | + | require.NoError(t, err) | |||
| 146 | + | ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "") | |||
| 147 | + | ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 }) | |||
| 148 | + | token := c.signIn(t) | |||
| 149 | + | ||||
| 150 | + | run := ci.sse("topic=repo:proj&view=run&n=1", "") | |||
| 151 | + | list := ci.sse("topic=repo:proj&view=actions", "") | |||
| 152 | + | job := runner.await() | |||
| 153 | + | jobStream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "") | |||
| 154 | + | _ = token | |||
| 155 | + | ||||
| 156 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a secret value in the log") | |||
| 157 | + | deadline := time.After(10 * time.Second) | |||
| 158 | + | var sawRun, sawList, sawJob bool | |||
| 159 | + | for !sawRun || !sawList || !sawJob { | |||
| 160 | + | select { | |||
| 161 | + | case ev := <-run.events: | |||
| 162 | + | sawRun = sawRun || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-body"`)) | |||
| 163 | + | assert.NotContains(t, ev.data, "a secret value in the log") | |||
| 164 | + | case ev := <-list.events: | |||
| 165 | + | sawList = sawList || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-1"`)) | |||
| 166 | + | case ev := <-jobStream.events: | |||
| 167 | + | sawJob = sawJob || (ev.name == "ci-job" && strings.Contains(ev.data, `id="job-head"`)) | |||
| 168 | + | assert.NotContains(t, ev.data, "a secret value in the log", "the stream of states never carries the log") | |||
| 169 | + | case <-deadline: | |||
| 170 | + | t.Fatalf("fragments: run %v, list %v, job %v", sawRun, sawList, sawJob) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | _ = domain.CISuccess | |||
| 174 | + | } | |||
M internal/app/testdata/web/blob-binary.html +10 -0
| @@ -83,6 +83,10 @@ | |||
| 83 | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 84 | </a> | |
| 85 | 85 | </li> | |
| 86 | + | <li> | |
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 88 | + | </a> | |
| 89 | + | </li> | |
| 86 | 90 | </ul> | |
| 87 | 91 | </nav> | |
| 88 | 92 | </header> | |
| @@ -308,6 +312,12 @@ | |||
| 308 | 312 | <span class="k">section</span> | |
| 309 | 313 | </a> | |
| 310 | 314 | </li> | |
| 315 | + | <li data-k="section actions"> | |
| 316 | + | <a href="/files/actions" tabindex="-1"> | |
| 317 | + | <span class="lbl">actions</span> | |
| 318 | + | <span class="k">section</span> | |
| 319 | + | </a> | |
| 320 | + | </li> | |
| 311 | 321 | <li data-k="branch feature/x"> | |
| 312 | 322 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 313 | 323 | <span class="lbl">feature/x</span> | |
| @@ -83,6 +83,10 @@ | |||||
| 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 84 | </a> | 84 | </a> | ||
| 85 | </li> | 85 | </li> | ||
| 86 | + | <li> | |||
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 88 | + | </a> | |||
| 89 | + | </li> | |||
| 86 | </ul> | 90 | </ul> | ||
| 87 | </nav> | 91 | </nav> | ||
| 88 | </header> | 92 | </header> | ||
| @@ -308,6 +312,12 @@ | |||||
| 308 | <span class="k">section</span> | 312 | <span class="k">section</span> | ||
| 309 | </a> | 313 | </a> | ||
| 310 | </li> | 314 | </li> | ||
| 315 | + | <li data-k="section actions"> | |||
| 316 | + | <a href="/files/actions" tabindex="-1"> | |||
| 317 | + | <span class="lbl">actions</span> | |||
| 318 | + | <span class="k">section</span> | |||
| 319 | + | </a> | |||
| 320 | + | </li> | |||
| 311 | <li data-k="branch feature/x"> | 321 | <li data-k="branch feature/x"> | ||
| 312 | <a href="/files/tree/feature/x" tabindex="-1"> | 322 | <a href="/files/tree/feature/x" tabindex="-1"> | ||
| 313 | <span class="lbl">feature/x</span> | 323 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/blob-code.html +10 -0
| @@ -83,6 +83,10 @@ | |||
| 83 | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 84 | </a> | |
| 85 | 85 | </li> | |
| 86 | + | <li> | |
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 88 | + | </a> | |
| 89 | + | </li> | |
| 86 | 90 | </ul> | |
| 87 | 91 | </nav> | |
| 88 | 92 | </header> | |
| @@ -378,6 +382,12 @@ | |||
| 378 | 382 | <span class="k">section</span> | |
| 379 | 383 | </a> | |
| 380 | 384 | </li> | |
| 385 | + | <li data-k="section actions"> | |
| 386 | + | <a href="/files/actions" tabindex="-1"> | |
| 387 | + | <span class="lbl">actions</span> | |
| 388 | + | <span class="k">section</span> | |
| 389 | + | </a> | |
| 390 | + | </li> | |
| 381 | 391 | <li data-k="branch feature/x"> | |
| 382 | 392 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 383 | 393 | <span class="lbl">feature/x</span> | |
| @@ -83,6 +83,10 @@ | |||||
| 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 84 | </a> | 84 | </a> | ||
| 85 | </li> | 85 | </li> | ||
| 86 | + | <li> | |||
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 88 | + | </a> | |||
| 89 | + | </li> | |||
| 86 | </ul> | 90 | </ul> | ||
| 87 | </nav> | 91 | </nav> | ||
| 88 | </header> | 92 | </header> | ||
| @@ -378,6 +382,12 @@ | |||||
| 378 | <span class="k">section</span> | 382 | <span class="k">section</span> | ||
| 379 | </a> | 383 | </a> | ||
| 380 | </li> | 384 | </li> | ||
| 385 | + | <li data-k="section actions"> | |||
| 386 | + | <a href="/files/actions" tabindex="-1"> | |||
| 387 | + | <span class="lbl">actions</span> | |||
| 388 | + | <span class="k">section</span> | |||
| 389 | + | </a> | |||
| 390 | + | </li> | |||
| 381 | <li data-k="branch feature/x"> | 391 | <li data-k="branch feature/x"> | ||
| 382 | <a href="/files/tree/feature/x" tabindex="-1"> | 392 | <a href="/files/tree/feature/x" tabindex="-1"> | ||
| 383 | <span class="lbl">feature/x</span> | 393 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/blob-empty.html +10 -0
| @@ -83,6 +83,10 @@ | |||
| 83 | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 84 | </a> | |
| 85 | 85 | </li> | |
| 86 | + | <li> | |
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 88 | + | </a> | |
| 89 | + | </li> | |
| 86 | 90 | </ul> | |
| 87 | 91 | </nav> | |
| 88 | 92 | </header> | |
| @@ -308,6 +312,12 @@ | |||
| 308 | 312 | <span class="k">section</span> | |
| 309 | 313 | </a> | |
| 310 | 314 | </li> | |
| 315 | + | <li data-k="section actions"> | |
| 316 | + | <a href="/files/actions" tabindex="-1"> | |
| 317 | + | <span class="lbl">actions</span> | |
| 318 | + | <span class="k">section</span> | |
| 319 | + | </a> | |
| 320 | + | </li> | |
| 311 | 321 | <li data-k="branch feature/x"> | |
| 312 | 322 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 313 | 323 | <span class="lbl">feature/x</span> | |
| @@ -83,6 +83,10 @@ | |||||
| 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 84 | </a> | 84 | </a> | ||
| 85 | </li> | 85 | </li> | ||
| 86 | + | <li> | |||
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 88 | + | </a> | |||
| 89 | + | </li> | |||
| 86 | </ul> | 90 | </ul> | ||
| 87 | </nav> | 91 | </nav> | ||
| 88 | </header> | 92 | </header> | ||
| @@ -308,6 +312,12 @@ | |||||
| 308 | <span class="k">section</span> | 312 | <span class="k">section</span> | ||
| 309 | </a> | 313 | </a> | ||
| 310 | </li> | 314 | </li> | ||
| 315 | + | <li data-k="section actions"> | |||
| 316 | + | <a href="/files/actions" tabindex="-1"> | |||
| 317 | + | <span class="lbl">actions</span> | |||
| 318 | + | <span class="k">section</span> | |||
| 319 | + | </a> | |||
| 320 | + | </li> | |||
| 311 | <li data-k="branch feature/x"> | 321 | <li data-k="branch feature/x"> | ||
| 312 | <a href="/files/tree/feature/x" tabindex="-1"> | 322 | <a href="/files/tree/feature/x" tabindex="-1"> | ||
| 313 | <span class="lbl">feature/x</span> | 323 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/blob-image.html +10 -0
| @@ -83,6 +83,10 @@ | |||
| 83 | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 84 | </a> | |
| 85 | 85 | </li> | |
| 86 | + | <li> | |
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 88 | + | </a> | |
| 89 | + | </li> | |
| 86 | 90 | </ul> | |
| 87 | 91 | </nav> | |
| 88 | 92 | </header> | |
| @@ -310,6 +314,12 @@ | |||
| 310 | 314 | <span class="k">section</span> | |
| 311 | 315 | </a> | |
| 312 | 316 | </li> | |
| 317 | + | <li data-k="section actions"> | |
| 318 | + | <a href="/files/actions" tabindex="-1"> | |
| 319 | + | <span class="lbl">actions</span> | |
| 320 | + | <span class="k">section</span> | |
| 321 | + | </a> | |
| 322 | + | </li> | |
| 313 | 323 | <li data-k="branch feature/x"> | |
| 314 | 324 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 315 | 325 | <span class="lbl">feature/x</span> | |
| @@ -83,6 +83,10 @@ | |||||
| 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 84 | </a> | 84 | </a> | ||
| 85 | </li> | 85 | </li> | ||
| 86 | + | <li> | |||
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 88 | + | </a> | |||
| 89 | + | </li> | |||
| 86 | </ul> | 90 | </ul> | ||
| 87 | </nav> | 91 | </nav> | ||
| 88 | </header> | 92 | </header> | ||
| @@ -310,6 +314,12 @@ | |||||
| 310 | <span class="k">section</span> | 314 | <span class="k">section</span> | ||
| 311 | </a> | 315 | </a> | ||
| 312 | </li> | 316 | </li> | ||
| 317 | + | <li data-k="section actions"> | |||
| 318 | + | <a href="/files/actions" tabindex="-1"> | |||
| 319 | + | <span class="lbl">actions</span> | |||
| 320 | + | <span class="k">section</span> | |||
| 321 | + | </a> | |||
| 322 | + | </li> | |||
| 313 | <li data-k="branch feature/x"> | 323 | <li data-k="branch feature/x"> | ||
| 314 | <a href="/files/tree/feature/x" tabindex="-1"> | 324 | <a href="/files/tree/feature/x" tabindex="-1"> | ||
| 315 | <span class="lbl">feature/x</span> | 325 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/blob-markdown.html +10 -0
| @@ -86,6 +86,10 @@ | |||
| 86 | 86 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 87 | 87 | </a> | |
| 88 | 88 | </li> | |
| 89 | + | <li> | |
| 90 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 91 | + | </a> | |
| 92 | + | </li> | |
| 89 | 93 | </ul> | |
| 90 | 94 | </nav> | |
| 91 | 95 | </header> | |
| @@ -375,6 +379,12 @@ | |||
| 375 | 379 | <span class="k">section</span> | |
| 376 | 380 | </a> | |
| 377 | 381 | </li> | |
| 382 | + | <li data-k="section actions"> | |
| 383 | + | <a href="/files/actions" tabindex="-1"> | |
| 384 | + | <span class="lbl">actions</span> | |
| 385 | + | <span class="k">section</span> | |
| 386 | + | </a> | |
| 387 | + | </li> | |
| 378 | 388 | <li data-k="branch feature/x"> | |
| 379 | 389 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 380 | 390 | <span class="lbl">feature/x</span> | |
| @@ -86,6 +86,10 @@ | |||||
| 86 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 86 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 87 | </a> | 87 | </a> | ||
| 88 | </li> | 88 | </li> | ||
| 89 | + | <li> | |||
| 90 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 91 | + | </a> | |||
| 92 | + | </li> | |||
| 89 | </ul> | 93 | </ul> | ||
| 90 | </nav> | 94 | </nav> | ||
| 91 | </header> | 95 | </header> | ||
| @@ -375,6 +379,12 @@ | |||||
| 375 | <span class="k">section</span> | 379 | <span class="k">section</span> | ||
| 376 | </a> | 380 | </a> | ||
| 377 | </li> | 381 | </li> | ||
| 382 | + | <li data-k="section actions"> | |||
| 383 | + | <a href="/files/actions" tabindex="-1"> | |||
| 384 | + | <span class="lbl">actions</span> | |||
| 385 | + | <span class="k">section</span> | |||
| 386 | + | </a> | |||
| 387 | + | </li> | |||
| 378 | <li data-k="branch feature/x"> | 388 | <li data-k="branch feature/x"> | ||
| 379 | <a href="/files/tree/feature/x" tabindex="-1"> | 389 | <a href="/files/tree/feature/x" tabindex="-1"> | ||
| 380 | <span class="lbl">feature/x</span> | 390 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/blob-svg.html +10 -0
| @@ -83,6 +83,10 @@ | |||
| 83 | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 84 | </a> | |
| 85 | 85 | </li> | |
| 86 | + | <li> | |
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 88 | + | </a> | |
| 89 | + | </li> | |
| 86 | 90 | </ul> | |
| 87 | 91 | </nav> | |
| 88 | 92 | </header> | |
| @@ -333,6 +337,12 @@ | |||
| 333 | 337 | <span class="k">section</span> | |
| 334 | 338 | </a> | |
| 335 | 339 | </li> | |
| 340 | + | <li data-k="section actions"> | |
| 341 | + | <a href="/files/actions" tabindex="-1"> | |
| 342 | + | <span class="lbl">actions</span> | |
| 343 | + | <span class="k">section</span> | |
| 344 | + | </a> | |
| 345 | + | </li> | |
| 336 | 346 | <li data-k="branch feature/x"> | |
| 337 | 347 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 338 | 348 | <span class="lbl">feature/x</span> | |
| @@ -83,6 +83,10 @@ | |||||
| 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 84 | </a> | 84 | </a> | ||
| 85 | </li> | 85 | </li> | ||
| 86 | + | <li> | |||
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 88 | + | </a> | |||
| 89 | + | </li> | |||
| 86 | </ul> | 90 | </ul> | ||
| 87 | </nav> | 91 | </nav> | ||
| 88 | </header> | 92 | </header> | ||
| @@ -333,6 +337,12 @@ | |||||
| 333 | <span class="k">section</span> | 337 | <span class="k">section</span> | ||
| 334 | </a> | 338 | </a> | ||
| 335 | </li> | 339 | </li> | ||
| 340 | + | <li data-k="section actions"> | |||
| 341 | + | <a href="/files/actions" tabindex="-1"> | |||
| 342 | + | <span class="lbl">actions</span> | |||
| 343 | + | <span class="k">section</span> | |||
| 344 | + | </a> | |||
| 345 | + | </li> | |||
| 336 | <li data-k="branch feature/x"> | 346 | <li data-k="branch feature/x"> | ||
| 337 | <a href="/files/tree/feature/x" tabindex="-1"> | 347 | <a href="/files/tree/feature/x" tabindex="-1"> | ||
| 338 | <span class="lbl">feature/x</span> | 348 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/blob-text.html +10 -0
| @@ -83,6 +83,10 @@ | |||
| 83 | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 84 | </a> | |
| 85 | 85 | </li> | |
| 86 | + | <li> | |
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 88 | + | </a> | |
| 89 | + | </li> | |
| 86 | 90 | </ul> | |
| 87 | 91 | </nav> | |
| 88 | 92 | </header> | |
| @@ -328,6 +332,12 @@ | |||
| 328 | 332 | <span class="k">section</span> | |
| 329 | 333 | </a> | |
| 330 | 334 | </li> | |
| 335 | + | <li data-k="section actions"> | |
| 336 | + | <a href="/files/actions" tabindex="-1"> | |
| 337 | + | <span class="lbl">actions</span> | |
| 338 | + | <span class="k">section</span> | |
| 339 | + | </a> | |
| 340 | + | </li> | |
| 331 | 341 | <li data-k="branch feature/x"> | |
| 332 | 342 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 333 | 343 | <span class="lbl">feature/x</span> | |
| @@ -83,6 +83,10 @@ | |||||
| 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 84 | </a> | 84 | </a> | ||
| 85 | </li> | 85 | </li> | ||
| 86 | + | <li> | |||
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 88 | + | </a> | |||
| 89 | + | </li> | |||
| 86 | </ul> | 90 | </ul> | ||
| 87 | </nav> | 91 | </nav> | ||
| 88 | </header> | 92 | </header> | ||
| @@ -328,6 +332,12 @@ | |||||
| 328 | <span class="k">section</span> | 332 | <span class="k">section</span> | ||
| 329 | </a> | 333 | </a> | ||
| 330 | </li> | 334 | </li> | ||
| 335 | + | <li data-k="section actions"> | |||
| 336 | + | <a href="/files/actions" tabindex="-1"> | |||
| 337 | + | <span class="lbl">actions</span> | |||
| 338 | + | <span class="k">section</span> | |||
| 339 | + | </a> | |||
| 340 | + | </li> | |||
| 331 | <li data-k="branch feature/x"> | 341 | <li data-k="branch feature/x"> | ||
| 332 | <a href="/files/tree/feature/x" tabindex="-1"> | 342 | <a href="/files/tree/feature/x" tabindex="-1"> | ||
| 333 | <span class="lbl">feature/x</span> | 343 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/blob-too-large.html +10 -0
| @@ -83,6 +83,10 @@ | |||
| 83 | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 84 | </a> | |
| 85 | 85 | </li> | |
| 86 | + | <li> | |
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 88 | + | </a> | |
| 89 | + | </li> | |
| 86 | 90 | </ul> | |
| 87 | 91 | </nav> | |
| 88 | 92 | </header> | |
| @@ -308,6 +312,12 @@ | |||
| 308 | 312 | <span class="k">section</span> | |
| 309 | 313 | </a> | |
| 310 | 314 | </li> | |
| 315 | + | <li data-k="section actions"> | |
| 316 | + | <a href="/files/actions" tabindex="-1"> | |
| 317 | + | <span class="lbl">actions</span> | |
| 318 | + | <span class="k">section</span> | |
| 319 | + | </a> | |
| 320 | + | </li> | |
| 311 | 321 | <li data-k="branch feature/x"> | |
| 312 | 322 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 313 | 323 | <span class="lbl">feature/x</span> | |
| @@ -83,6 +83,10 @@ | |||||
| 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 84 | </a> | 84 | </a> | ||
| 85 | </li> | 85 | </li> | ||
| 86 | + | <li> | |||
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 88 | + | </a> | |||
| 89 | + | </li> | |||
| 86 | </ul> | 90 | </ul> | ||
| 87 | </nav> | 91 | </nav> | ||
| 88 | </header> | 92 | </header> | ||
| @@ -308,6 +312,12 @@ | |||||
| 308 | <span class="k">section</span> | 312 | <span class="k">section</span> | ||
| 309 | </a> | 313 | </a> | ||
| 310 | </li> | 314 | </li> | ||
| 315 | + | <li data-k="section actions"> | |||
| 316 | + | <a href="/files/actions" tabindex="-1"> | |||
| 317 | + | <span class="lbl">actions</span> | |||
| 318 | + | <span class="k">section</span> | |||
| 319 | + | </a> | |||
| 320 | + | </li> | |||
| 311 | <li data-k="branch feature/x"> | 321 | <li data-k="branch feature/x"> | ||
| 312 | <a href="/files/tree/feature/x" tabindex="-1"> | 322 | <a href="/files/tree/feature/x" tabindex="-1"> | ||
| 313 | <span class="lbl">feature/x</span> | 323 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/blob-trojan.html +10 -0
| @@ -83,6 +83,10 @@ | |||
| 83 | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 84 | </a> | |
| 85 | 85 | </li> | |
| 86 | + | <li> | |
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 88 | + | </a> | |
| 89 | + | </li> | |
| 86 | 90 | </ul> | |
| 87 | 91 | </nav> | |
| 88 | 92 | </header> | |
| @@ -375,6 +379,12 @@ | |||
| 375 | 379 | <span class="k">section</span> | |
| 376 | 380 | </a> | |
| 377 | 381 | </li> | |
| 382 | + | <li data-k="section actions"> | |
| 383 | + | <a href="/files/actions" tabindex="-1"> | |
| 384 | + | <span class="lbl">actions</span> | |
| 385 | + | <span class="k">section</span> | |
| 386 | + | </a> | |
| 387 | + | </li> | |
| 378 | 388 | <li data-k="branch feature/x"> | |
| 379 | 389 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 380 | 390 | <span class="lbl">feature/x</span> | |
| @@ -83,6 +83,10 @@ | |||||
| 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 84 | </a> | 84 | </a> | ||
| 85 | </li> | 85 | </li> | ||
| 86 | + | <li> | |||
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 88 | + | </a> | |||
| 89 | + | </li> | |||
| 86 | </ul> | 90 | </ul> | ||
| 87 | </nav> | 91 | </nav> | ||
| 88 | </header> | 92 | </header> | ||
| @@ -375,6 +379,12 @@ | |||||
| 375 | <span class="k">section</span> | 379 | <span class="k">section</span> | ||
| 376 | </a> | 380 | </a> | ||
| 377 | </li> | 381 | </li> | ||
| 382 | + | <li data-k="section actions"> | |||
| 383 | + | <a href="/files/actions" tabindex="-1"> | |||
| 384 | + | <span class="lbl">actions</span> | |||
| 385 | + | <span class="k">section</span> | |||
| 386 | + | </a> | |||
| 387 | + | </li> | |||
| 378 | <li data-k="branch feature/x"> | 388 | <li data-k="branch feature/x"> | ||
| 379 | <a href="/files/tree/feature/x" tabindex="-1"> | 389 | <a href="/files/tree/feature/x" tabindex="-1"> | ||
| 380 | <span class="lbl">feature/x</span> | 390 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/commit-big.html +10 -0
| @@ -80,6 +80,10 @@ | |||
| 80 | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 81 | </a> | |
| 82 | 82 | </li> | |
| 83 | + | <li> | |
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 85 | + | </a> | |
| 86 | + | </li> | |
| 83 | 87 | </ul> | |
| 84 | 88 | </nav> | |
| 85 | 89 | </header> | |
| @@ -345,6 +349,12 @@ | |||
| 345 | 349 | <span class="k">section</span> | |
| 346 | 350 | </a> | |
| 347 | 351 | </li> | |
| 352 | + | <li data-k="section actions"> | |
| 353 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 354 | + | <span class="lbl">actions</span> | |
| 355 | + | <span class="k">section</span> | |
| 356 | + | </a> | |
| 357 | + | </li> | |
| 348 | 358 | <li data-k="branch main"> | |
| 349 | 359 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 350 | 360 | <span class="lbl">main</span> | |
| @@ -80,6 +80,10 @@ | |||||
| 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 81 | </a> | 81 | </a> | ||
| 82 | </li> | 82 | </li> | ||
| 83 | + | <li> | |||
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 85 | + | </a> | |||
| 86 | + | </li> | |||
| 83 | </ul> | 87 | </ul> | ||
| 84 | </nav> | 88 | </nav> | ||
| 85 | </header> | 89 | </header> | ||
| @@ -345,6 +349,12 @@ | |||||
| 345 | <span class="k">section</span> | 349 | <span class="k">section</span> | ||
| 346 | </a> | 350 | </a> | ||
| 347 | </li> | 351 | </li> | ||
| 352 | + | <li data-k="section actions"> | |||
| 353 | + | <a href="/diffs/actions" tabindex="-1"> | |||
| 354 | + | <span class="lbl">actions</span> | |||
| 355 | + | <span class="k">section</span> | |||
| 356 | + | </a> | |||
| 357 | + | </li> | |||
| 348 | <li data-k="branch main"> | 358 | <li data-k="branch main"> | ||
| 349 | <a href="/diffs/tree/main" tabindex="-1"> | 359 | <a href="/diffs/tree/main" tabindex="-1"> | ||
| 350 | <span class="lbl">main</span> | 360 | <span class="lbl">main</span> | ||
M internal/app/testdata/web/commit-binary.html +10 -0
| @@ -80,6 +80,10 @@ | |||
| 80 | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 81 | </a> | |
| 82 | 82 | </li> | |
| 83 | + | <li> | |
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 85 | + | </a> | |
| 86 | + | </li> | |
| 83 | 87 | </ul> | |
| 84 | 88 | </nav> | |
| 85 | 89 | </header> | |
| @@ -340,6 +344,12 @@ | |||
| 340 | 344 | <span class="k">section</span> | |
| 341 | 345 | </a> | |
| 342 | 346 | </li> | |
| 347 | + | <li data-k="section actions"> | |
| 348 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 349 | + | <span class="lbl">actions</span> | |
| 350 | + | <span class="k">section</span> | |
| 351 | + | </a> | |
| 352 | + | </li> | |
| 343 | 353 | <li data-k="branch main"> | |
| 344 | 354 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 345 | 355 | <span class="lbl">main</span> | |
| @@ -80,6 +80,10 @@ | |||||
| 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 81 | </a> | 81 | </a> | ||
| 82 | </li> | 82 | </li> | ||
| 83 | + | <li> | |||
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 85 | + | </a> | |||
| 86 | + | </li> | |||
| 83 | </ul> | 87 | </ul> | ||
| 84 | </nav> | 88 | </nav> | ||
| 85 | </header> | 89 | </header> | ||
| @@ -340,6 +344,12 @@ | |||||
| 340 | <span class="k">section</span> | 344 | <span class="k">section</span> | ||
| 341 | </a> | 345 | </a> | ||
| 342 | </li> | 346 | </li> | ||
| 347 | + | <li data-k="section actions"> | |||
| 348 | + | <a href="/diffs/actions" tabindex="-1"> | |||
| 349 | + | <span class="lbl">actions</span> | |||
| 350 | + | <span class="k">section</span> | |||
| 351 | + | </a> | |||
| 352 | + | </li> | |||
| 343 | <li data-k="branch main"> | 353 | <li data-k="branch main"> | ||
| 344 | <a href="/diffs/tree/main" tabindex="-1"> | 354 | <a href="/diffs/tree/main" tabindex="-1"> | ||
| 345 | <span class="lbl">main</span> | 355 | <span class="lbl">main</span> | ||
M internal/app/testdata/web/commit-merge.html +10 -0
| @@ -80,6 +80,10 @@ | |||
| 80 | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 81 | </a> | |
| 82 | 82 | </li> | |
| 83 | + | <li> | |
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 85 | + | </a> | |
| 86 | + | </li> | |
| 83 | 87 | </ul> | |
| 84 | 88 | </nav> | |
| 85 | 89 | </header> | |
| @@ -379,6 +383,12 @@ | |||
| 379 | 383 | <span class="k">section</span> | |
| 380 | 384 | </a> | |
| 381 | 385 | </li> | |
| 386 | + | <li data-k="section actions"> | |
| 387 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 388 | + | <span class="lbl">actions</span> | |
| 389 | + | <span class="k">section</span> | |
| 390 | + | </a> | |
| 391 | + | </li> | |
| 382 | 392 | <li data-k="branch main"> | |
| 383 | 393 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 384 | 394 | <span class="lbl">main</span> | |
| @@ -80,6 +80,10 @@ | |||||
| 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 81 | </a> | 81 | </a> | ||
| 82 | </li> | 82 | </li> | ||
| 83 | + | <li> | |||
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 85 | + | </a> | |||
| 86 | + | </li> | |||
| 83 | </ul> | 87 | </ul> | ||
| 84 | </nav> | 88 | </nav> | ||
| 85 | </header> | 89 | </header> | ||
| @@ -379,6 +383,12 @@ | |||||
| 379 | <span class="k">section</span> | 383 | <span class="k">section</span> | ||
| 380 | </a> | 384 | </a> | ||
| 381 | </li> | 385 | </li> | ||
| 386 | + | <li data-k="section actions"> | |||
| 387 | + | <a href="/diffs/actions" tabindex="-1"> | |||
| 388 | + | <span class="lbl">actions</span> | |||
| 389 | + | <span class="k">section</span> | |||
| 390 | + | </a> | |||
| 391 | + | </li> | |||
| 382 | <li data-k="branch main"> | 392 | <li data-k="branch main"> | ||
| 383 | <a href="/diffs/tree/main" tabindex="-1"> | 393 | <a href="/diffs/tree/main" tabindex="-1"> | ||
| 384 | <span class="lbl">main</span> | 394 | <span class="lbl">main</span> | ||
M internal/app/testdata/web/commit-modify-split.html +10 -0
| @@ -80,6 +80,10 @@ | |||
| 80 | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 81 | </a> | |
| 82 | 82 | </li> | |
| 83 | + | <li> | |
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 85 | + | </a> | |
| 86 | + | </li> | |
| 83 | 87 | </ul> | |
| 84 | 88 | </nav> | |
| 85 | 89 | </header> | |
| @@ -500,6 +504,12 @@ And <b>html</b> & "quotes".</pre> | |||
| 500 | 504 | <span class="k">section</span> | |
| 501 | 505 | </a> | |
| 502 | 506 | </li> | |
| 507 | + | <li data-k="section actions"> | |
| 508 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 509 | + | <span class="lbl">actions</span> | |
| 510 | + | <span class="k">section</span> | |
| 511 | + | </a> | |
| 512 | + | </li> | |
| 503 | 513 | <li data-k="branch main"> | |
| 504 | 514 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 505 | 515 | <span class="lbl">main</span> | |
| @@ -80,6 +80,10 @@ | |||||
| 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 81 | </a> | 81 | </a> | ||
| 82 | </li> | 82 | </li> | ||
| 83 | + | <li> | |||
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 85 | + | </a> | |||
| 86 | + | </li> | |||
| 83 | </ul> | 87 | </ul> | ||
| 84 | </nav> | 88 | </nav> | ||
| 85 | </header> | 89 | </header> | ||
| @@ -500,6 +504,12 @@ And <b>html</b> & "quotes".</pre> | |||||
| 500 | <span class="k">section</span> | 504 | <span class="k">section</span> | ||
| 501 | </a> | 505 | </a> | ||
| 502 | </li> | 506 | </li> | ||
| 507 | + | <li data-k="section actions"> | |||
| 508 | + | <a href="/diffs/actions" tabindex="-1"> | |||
| 509 | + | <span class="lbl">actions</span> | |||
| 510 | + | <span class="k">section</span> | |||
| 511 | + | </a> | |||
| 512 | + | </li> | |||
| 503 | <li data-k="branch main"> | 513 | <li data-k="branch main"> | ||
| 504 | <a href="/diffs/tree/main" tabindex="-1"> | 514 | <a href="/diffs/tree/main" tabindex="-1"> | ||
| 505 | <span class="lbl">main</span> | 515 | <span class="lbl">main</span> | ||
M internal/app/testdata/web/commit-modify.html +10 -0
| @@ -80,6 +80,10 @@ | |||
| 80 | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 81 | </a> | |
| 82 | 82 | </li> | |
| 83 | + | <li> | |
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 85 | + | </a> | |
| 86 | + | </li> | |
| 83 | 87 | </ul> | |
| 84 | 88 | </nav> | |
| 85 | 89 | </header> | |
| @@ -500,6 +504,12 @@ And <b>html</b> & "quotes".</pre> | |||
| 500 | 504 | <span class="k">section</span> | |
| 501 | 505 | </a> | |
| 502 | 506 | </li> | |
| 507 | + | <li data-k="section actions"> | |
| 508 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 509 | + | <span class="lbl">actions</span> | |
| 510 | + | <span class="k">section</span> | |
| 511 | + | </a> | |
| 512 | + | </li> | |
| 503 | 513 | <li data-k="branch main"> | |
| 504 | 514 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 505 | 515 | <span class="lbl">main</span> | |
| @@ -80,6 +80,10 @@ | |||||
| 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 81 | </a> | 81 | </a> | ||
| 82 | </li> | 82 | </li> | ||
| 83 | + | <li> | |||
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 85 | + | </a> | |||
| 86 | + | </li> | |||
| 83 | </ul> | 87 | </ul> | ||
| 84 | </nav> | 88 | </nav> | ||
| 85 | </header> | 89 | </header> | ||
| @@ -500,6 +504,12 @@ And <b>html</b> & "quotes".</pre> | |||||
| 500 | <span class="k">section</span> | 504 | <span class="k">section</span> | ||
| 501 | </a> | 505 | </a> | ||
| 502 | </li> | 506 | </li> | ||
| 507 | + | <li data-k="section actions"> | |||
| 508 | + | <a href="/diffs/actions" tabindex="-1"> | |||
| 509 | + | <span class="lbl">actions</span> | |||
| 510 | + | <span class="k">section</span> | |||
| 511 | + | </a> | |||
| 512 | + | </li> | |||
| 503 | <li data-k="branch main"> | 513 | <li data-k="branch main"> | ||
| 504 | <a href="/diffs/tree/main" tabindex="-1"> | 514 | <a href="/diffs/tree/main" tabindex="-1"> | ||
| 505 | <span class="lbl">main</span> | 515 | <span class="lbl">main</span> | ||
M internal/app/testdata/web/commit-rename.html +10 -0
| @@ -80,6 +80,10 @@ | |||
| 80 | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 81 | </a> | |
| 82 | 82 | </li> | |
| 83 | + | <li> | |
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 85 | + | </a> | |
| 86 | + | </li> | |
| 83 | 87 | </ul> | |
| 84 | 88 | </nav> | |
| 85 | 89 | </header> | |
| @@ -405,6 +409,12 @@ | |||
| 405 | 409 | <span class="k">section</span> | |
| 406 | 410 | </a> | |
| 407 | 411 | </li> | |
| 412 | + | <li data-k="section actions"> | |
| 413 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 414 | + | <span class="lbl">actions</span> | |
| 415 | + | <span class="k">section</span> | |
| 416 | + | </a> | |
| 417 | + | </li> | |
| 408 | 418 | <li data-k="branch main"> | |
| 409 | 419 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 410 | 420 | <span class="lbl">main</span> | |
| @@ -80,6 +80,10 @@ | |||||
| 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 81 | </a> | 81 | </a> | ||
| 82 | </li> | 82 | </li> | ||
| 83 | + | <li> | |||
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 85 | + | </a> | |||
| 86 | + | </li> | |||
| 83 | </ul> | 87 | </ul> | ||
| 84 | </nav> | 88 | </nav> | ||
| 85 | </header> | 89 | </header> | ||
| @@ -405,6 +409,12 @@ | |||||
| 405 | <span class="k">section</span> | 409 | <span class="k">section</span> | ||
| 406 | </a> | 410 | </a> | ||
| 407 | </li> | 411 | </li> | ||
| 412 | + | <li data-k="section actions"> | |||
| 413 | + | <a href="/diffs/actions" tabindex="-1"> | |||
| 414 | + | <span class="lbl">actions</span> | |||
| 415 | + | <span class="k">section</span> | |||
| 416 | + | </a> | |||
| 417 | + | </li> | |||
| 408 | <li data-k="branch main"> | 418 | <li data-k="branch main"> | ||
| 409 | <a href="/diffs/tree/main" tabindex="-1"> | 419 | <a href="/diffs/tree/main" tabindex="-1"> | ||
| 410 | <span class="lbl">main</span> | 420 | <span class="lbl">main</span> | ||
M internal/app/testdata/web/commit-root.html +10 -0
| @@ -80,6 +80,10 @@ | |||
| 80 | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 81 | </a> | |
| 82 | 82 | </li> | |
| 83 | + | <li> | |
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 85 | + | </a> | |
| 86 | + | </li> | |
| 83 | 87 | </ul> | |
| 84 | 88 | </nav> | |
| 85 | 89 | </header> | |
| @@ -721,6 +725,12 @@ | |||
| 721 | 725 | <span class="k">section</span> | |
| 722 | 726 | </a> | |
| 723 | 727 | </li> | |
| 728 | + | <li data-k="section actions"> | |
| 729 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 730 | + | <span class="lbl">actions</span> | |
| 731 | + | <span class="k">section</span> | |
| 732 | + | </a> | |
| 733 | + | </li> | |
| 724 | 734 | <li data-k="branch main"> | |
| 725 | 735 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 726 | 736 | <span class="lbl">main</span> | |
| @@ -80,6 +80,10 @@ | |||||
| 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 81 | </a> | 81 | </a> | ||
| 82 | </li> | 82 | </li> | ||
| 83 | + | <li> | |||
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 85 | + | </a> | |||
| 86 | + | </li> | |||
| 83 | </ul> | 87 | </ul> | ||
| 84 | </nav> | 88 | </nav> | ||
| 85 | </header> | 89 | </header> | ||
| @@ -721,6 +725,12 @@ | |||||
| 721 | <span class="k">section</span> | 725 | <span class="k">section</span> | ||
| 722 | </a> | 726 | </a> | ||
| 723 | </li> | 727 | </li> | ||
| 728 | + | <li data-k="section actions"> | |||
| 729 | + | <a href="/diffs/actions" tabindex="-1"> | |||
| 730 | + | <span class="lbl">actions</span> | |||
| 731 | + | <span class="k">section</span> | |||
| 732 | + | </a> | |||
| 733 | + | </li> | |||
| 724 | <li data-k="branch main"> | 734 | <li data-k="branch main"> | ||
| 725 | <a href="/diffs/tree/main" tabindex="-1"> | 735 | <a href="/diffs/tree/main" tabindex="-1"> | ||
| 726 | <span class="lbl">main</span> | 736 | <span class="lbl">main</span> | ||
M internal/app/testdata/web/commit-trojan.html +10 -0
| @@ -80,6 +80,10 @@ | |||
| 80 | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 81 | </a> | |
| 82 | 82 | </li> | |
| 83 | + | <li> | |
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 85 | + | </a> | |
| 86 | + | </li> | |
| 83 | 87 | </ul> | |
| 84 | 88 | </nav> | |
| 85 | 89 | </header> | |
| @@ -514,6 +518,12 @@ | |||
| 514 | 518 | <span class="k">section</span> | |
| 515 | 519 | </a> | |
| 516 | 520 | </li> | |
| 521 | + | <li data-k="section actions"> | |
| 522 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 523 | + | <span class="lbl">actions</span> | |
| 524 | + | <span class="k">section</span> | |
| 525 | + | </a> | |
| 526 | + | </li> | |
| 517 | 527 | <li data-k="branch main"> | |
| 518 | 528 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 519 | 529 | <span class="lbl">main</span> | |
| @@ -80,6 +80,10 @@ | |||||
| 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 81 | </a> | 81 | </a> | ||
| 82 | </li> | 82 | </li> | ||
| 83 | + | <li> | |||
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 85 | + | </a> | |||
| 86 | + | </li> | |||
| 83 | </ul> | 87 | </ul> | ||
| 84 | </nav> | 88 | </nav> | ||
| 85 | </header> | 89 | </header> | ||
| @@ -514,6 +518,12 @@ | |||||
| 514 | <span class="k">section</span> | 518 | <span class="k">section</span> | ||
| 515 | </a> | 519 | </a> | ||
| 516 | </li> | 520 | </li> | ||
| 521 | + | <li data-k="section actions"> | |||
| 522 | + | <a href="/diffs/actions" tabindex="-1"> | |||
| 523 | + | <span class="lbl">actions</span> | |||
| 524 | + | <span class="k">section</span> | |||
| 525 | + | </a> | |||
| 526 | + | </li> | |||
| 517 | <li data-k="branch main"> | 527 | <li data-k="branch main"> | ||
| 518 | <a href="/diffs/tree/main" tabindex="-1"> | 528 | <a href="/diffs/tree/main" tabindex="-1"> | ||
| 519 | <span class="lbl">main</span> | 529 | <span class="lbl">main</span> | ||
M internal/app/testdata/web/commits-page2.html +14 -0
| @@ -80,6 +80,10 @@ | |||
| 80 | 80 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 81 | </a> | |
| 82 | 82 | </li> | |
| 83 | + | <li> | |
| 84 | + | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 85 | + | </a> | |
| 86 | + | </li> | |
| 83 | 87 | </ul> | |
| 84 | 88 | </nav> | |
| 85 | 89 | </header> | |
| @@ -103,6 +107,8 @@ | |||
| 103 | 107 | <td class="c-subject"> | |
| 104 | 108 | <a href="/long/commit/SHA40">commit number 1</a> | |
| 105 | 109 | </td> | |
| 110 | + | <td class="c-ck"> | |
| 111 | + | </td> | |
| 106 | 112 | <td class="c-who dim">T</td> | |
| 107 | 113 | <td class="c-when nowrap dim"> | |
| 108 | 114 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -115,6 +121,8 @@ | |||
| 115 | 121 | <td class="c-subject"> | |
| 116 | 122 | <a href="/long/commit/SHA40">commit number 0</a> | |
| 117 | 123 | </td> | |
| 124 | + | <td class="c-ck"> | |
| 125 | + | </td> | |
| 118 | 126 | <td class="c-who dim">T</td> | |
| 119 | 127 | <td class="c-when nowrap dim"> | |
| 120 | 128 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -327,6 +335,12 @@ | |||
| 327 | 335 | <span class="k">section</span> | |
| 328 | 336 | </a> | |
| 329 | 337 | </li> | |
| 338 | + | <li data-k="section actions"> | |
| 339 | + | <a href="/long/actions" tabindex="-1"> | |
| 340 | + | <span class="lbl">actions</span> | |
| 341 | + | <span class="k">section</span> | |
| 342 | + | </a> | |
| 343 | + | </li> | |
| 330 | 344 | <li data-k="branch main"> | |
| 331 | 345 | <a href="/long/tree/main" tabindex="-1"> | |
| 332 | 346 | <span class="lbl">main</span> | |
| @@ -80,6 +80,10 @@ | |||||
| 80 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 80 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 81 | </a> | 81 | </a> | ||
| 82 | </li> | 82 | </li> | ||
| 83 | + | <li> | |||
| 84 | + | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 85 | + | </a> | |||
| 86 | + | </li> | |||
| 83 | </ul> | 87 | </ul> | ||
| 84 | </nav> | 88 | </nav> | ||
| 85 | </header> | 89 | </header> | ||
| @@ -103,6 +107,8 @@ | |||||
| 103 | <td class="c-subject"> | 107 | <td class="c-subject"> | ||
| 104 | <a href="/long/commit/SHA40">commit number 1</a> | 108 | <a href="/long/commit/SHA40">commit number 1</a> | ||
| 105 | </td> | 109 | </td> | ||
| 110 | + | <td class="c-ck"> | |||
| 111 | + | </td> | |||
| 106 | <td class="c-who dim">T</td> | 112 | <td class="c-who dim">T</td> | ||
| 107 | <td class="c-when nowrap dim"> | 113 | <td class="c-when nowrap dim"> | ||
| 108 | <time datetime="STAMP" title="STAMP">AGO</time> | 114 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -115,6 +121,8 @@ | |||||
| 115 | <td class="c-subject"> | 121 | <td class="c-subject"> | ||
| 116 | <a href="/long/commit/SHA40">commit number 0</a> | 122 | <a href="/long/commit/SHA40">commit number 0</a> | ||
| 117 | </td> | 123 | </td> | ||
| 124 | + | <td class="c-ck"> | |||
| 125 | + | </td> | |||
| 118 | <td class="c-who dim">T</td> | 126 | <td class="c-who dim">T</td> | ||
| 119 | <td class="c-when nowrap dim"> | 127 | <td class="c-when nowrap dim"> | ||
| 120 | <time datetime="STAMP" title="STAMP">AGO</time> | 128 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -327,6 +335,12 @@ | |||||
| 327 | <span class="k">section</span> | 335 | <span class="k">section</span> | ||
| 328 | </a> | 336 | </a> | ||
| 329 | </li> | 337 | </li> | ||
| 338 | + | <li data-k="section actions"> | |||
| 339 | + | <a href="/long/actions" tabindex="-1"> | |||
| 340 | + | <span class="lbl">actions</span> | |||
| 341 | + | <span class="k">section</span> | |||
| 342 | + | </a> | |||
| 343 | + | </li> | |||
| 330 | <li data-k="branch main"> | 344 | <li data-k="branch main"> | ||
| 331 | <a href="/long/tree/main" tabindex="-1"> | 345 | <a href="/long/tree/main" tabindex="-1"> | ||
| 332 | <span class="lbl">main</span> | 346 | <span class="lbl">main</span> | ||
M internal/app/testdata/web/commits-path.html +14 -0
| @@ -83,6 +83,10 @@ | |||
| 83 | 83 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 84 | </a> | |
| 85 | 85 | </li> | |
| 86 | + | <li> | |
| 87 | + | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 88 | + | </a> | |
| 89 | + | </li> | |
| 86 | 90 | </ul> | |
| 87 | 91 | </nav> | |
| 88 | 92 | </header> | |
| @@ -107,6 +111,8 @@ | |||
| 107 | 111 | <td class="c-subject"> | |
| 108 | 112 | <a href="/long/commit/SHA40">commit number 30</a> | |
| 109 | 113 | </td> | |
| 114 | + | <td class="c-ck"> | |
| 115 | + | </td> | |
| 110 | 116 | <td class="c-who dim">T</td> | |
| 111 | 117 | <td class="c-when nowrap dim"> | |
| 112 | 118 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -128,6 +134,8 @@ | |||
| 128 | 134 | <td class="c-subject"> | |
| 129 | 135 | <a href="/long/commit/SHA40">commit number 3</a> | |
| 130 | 136 | </td> | |
| 137 | + | <td class="c-ck"> | |
| 138 | + | </td> | |
| 131 | 139 | <td class="c-who dim">T</td> | |
| 132 | 140 | <td class="c-when nowrap dim"> | |
| 133 | 141 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -342,6 +350,12 @@ | |||
| 342 | 350 | <span class="k">section</span> | |
| 343 | 351 | </a> | |
| 344 | 352 | </li> | |
| 353 | + | <li data-k="section actions"> | |
| 354 | + | <a href="/long/actions" tabindex="-1"> | |
| 355 | + | <span class="lbl">actions</span> | |
| 356 | + | <span class="k">section</span> | |
| 357 | + | </a> | |
| 358 | + | </li> | |
| 345 | 359 | <li data-k="branch main"> | |
| 346 | 360 | <a href="/long/tree/main" tabindex="-1"> | |
| 347 | 361 | <span class="lbl">main</span> | |
| @@ -83,6 +83,10 @@ | |||||
| 83 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 83 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 84 | </a> | 84 | </a> | ||
| 85 | </li> | 85 | </li> | ||
| 86 | + | <li> | |||
| 87 | + | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 88 | + | </a> | |||
| 89 | + | </li> | |||
| 86 | </ul> | 90 | </ul> | ||
| 87 | </nav> | 91 | </nav> | ||
| 88 | </header> | 92 | </header> | ||
| @@ -107,6 +111,8 @@ | |||||
| 107 | <td class="c-subject"> | 111 | <td class="c-subject"> | ||
| 108 | <a href="/long/commit/SHA40">commit number 30</a> | 112 | <a href="/long/commit/SHA40">commit number 30</a> | ||
| 109 | </td> | 113 | </td> | ||
| 114 | + | <td class="c-ck"> | |||
| 115 | + | </td> | |||
| 110 | <td class="c-who dim">T</td> | 116 | <td class="c-who dim">T</td> | ||
| 111 | <td class="c-when nowrap dim"> | 117 | <td class="c-when nowrap dim"> | ||
| 112 | <time datetime="STAMP" title="STAMP">AGO</time> | 118 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -128,6 +134,8 @@ | |||||
| 128 | <td class="c-subject"> | 134 | <td class="c-subject"> | ||
| 129 | <a href="/long/commit/SHA40">commit number 3</a> | 135 | <a href="/long/commit/SHA40">commit number 3</a> | ||
| 130 | </td> | 136 | </td> | ||
| 137 | + | <td class="c-ck"> | |||
| 138 | + | </td> | |||
| 131 | <td class="c-who dim">T</td> | 139 | <td class="c-who dim">T</td> | ||
| 132 | <td class="c-when nowrap dim"> | 140 | <td class="c-when nowrap dim"> | ||
| 133 | <time datetime="STAMP" title="STAMP">AGO</time> | 141 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -342,6 +350,12 @@ | |||||
| 342 | <span class="k">section</span> | 350 | <span class="k">section</span> | ||
| 343 | </a> | 351 | </a> | ||
| 344 | </li> | 352 | </li> | ||
| 353 | + | <li data-k="section actions"> | |||
| 354 | + | <a href="/long/actions" tabindex="-1"> | |||
| 355 | + | <span class="lbl">actions</span> | |||
| 356 | + | <span class="k">section</span> | |||
| 357 | + | </a> | |||
| 358 | + | </li> | |||
| 345 | <li data-k="branch main"> | 359 | <li data-k="branch main"> | ||
| 346 | <a href="/long/tree/main" tabindex="-1"> | 360 | <a href="/long/tree/main" tabindex="-1"> | ||
| 347 | <span class="lbl">main</span> | 361 | <span class="lbl">main</span> | ||
M internal/app/testdata/web/commits-root.html +111 -1
| @@ -80,6 +80,10 @@ | |||
| 80 | 80 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 81 | </a> | |
| 82 | 82 | </li> | |
| 83 | + | <li> | |
| 84 | + | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 85 | + | </a> | |
| 86 | + | </li> | |
| 83 | 87 | </ul> | |
| 84 | 88 | </nav> | |
| 85 | 89 | </header> | |
| @@ -106,6 +110,8 @@ | |||
| 106 | 110 | <a href="/long/commit/SHA40">commit number 51</a> | |
| 107 | 111 | <span class="pill">main</span> | |
| 108 | 112 | </td> | |
| 113 | + | <td class="c-ck"> | |
| 114 | + | </td> | |
| 109 | 115 | <td class="c-who dim">T</td> | |
| 110 | 116 | <td class="c-when nowrap dim"> | |
| 111 | 117 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -118,6 +124,8 @@ | |||
| 118 | 124 | <td class="c-subject"> | |
| 119 | 125 | <a href="/long/commit/SHA40">commit number 50</a> | |
| 120 | 126 | </td> | |
| 127 | + | <td class="c-ck"> | |
| 128 | + | </td> | |
| 121 | 129 | <td class="c-who dim">T</td> | |
| 122 | 130 | <td class="c-when nowrap dim"> | |
| 123 | 131 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -139,6 +147,8 @@ | |||
| 139 | 147 | <td class="c-subject"> | |
| 140 | 148 | <a href="/long/commit/SHA40">commit number 49</a> | |
| 141 | 149 | </td> | |
| 150 | + | <td class="c-ck"> | |
| 151 | + | </td> | |
| 142 | 152 | <td class="c-who dim">T</td> | |
| 143 | 153 | <td class="c-when nowrap dim"> | |
| 144 | 154 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -151,6 +161,8 @@ | |||
| 151 | 161 | <td class="c-subject"> | |
| 152 | 162 | <a href="/long/commit/SHA40">commit number 48</a> | |
| 153 | 163 | </td> | |
| 164 | + | <td class="c-ck"> | |
| 165 | + | </td> | |
| 154 | 166 | <td class="c-who dim">T</td> | |
| 155 | 167 | <td class="c-when nowrap dim"> | |
| 156 | 168 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -172,6 +184,8 @@ | |||
| 172 | 184 | <td class="c-subject"> | |
| 173 | 185 | <a href="/long/commit/SHA40">commit number 47</a> | |
| 174 | 186 | </td> | |
| 187 | + | <td class="c-ck"> | |
| 188 | + | </td> | |
| 175 | 189 | <td class="c-who dim">T</td> | |
| 176 | 190 | <td class="c-when nowrap dim"> | |
| 177 | 191 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -184,6 +198,8 @@ | |||
| 184 | 198 | <td class="c-subject"> | |
| 185 | 199 | <a href="/long/commit/SHA40">commit number 46</a> | |
| 186 | 200 | </td> | |
| 201 | + | <td class="c-ck"> | |
| 202 | + | </td> | |
| 187 | 203 | <td class="c-who dim">T</td> | |
| 188 | 204 | <td class="c-when nowrap dim"> | |
| 189 | 205 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -205,6 +221,8 @@ | |||
| 205 | 221 | <td class="c-subject"> | |
| 206 | 222 | <a href="/long/commit/SHA40">commit number 45</a> | |
| 207 | 223 | </td> | |
| 224 | + | <td class="c-ck"> | |
| 225 | + | </td> | |
| 208 | 226 | <td class="c-who dim">T</td> | |
| 209 | 227 | <td class="c-when nowrap dim"> | |
| 210 | 228 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -217,6 +235,8 @@ | |||
| 217 | 235 | <td class="c-subject"> | |
| 218 | 236 | <a href="/long/commit/SHA40">commit number 44</a> | |
| 219 | 237 | </td> | |
| 238 | + | <td class="c-ck"> | |
| 239 | + | </td> | |
| 220 | 240 | <td class="c-who dim">T</td> | |
| 221 | 241 | <td class="c-when nowrap dim"> | |
| 222 | 242 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -238,6 +258,8 @@ | |||
| 238 | 258 | <td class="c-subject"> | |
| 239 | 259 | <a href="/long/commit/SHA40">commit number 43</a> | |
| 240 | 260 | </td> | |
| 261 | + | <td class="c-ck"> | |
| 262 | + | </td> | |
| 241 | 263 | <td class="c-who dim">T</td> | |
| 242 | 264 | <td class="c-when nowrap dim"> | |
| 243 | 265 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -250,6 +272,8 @@ | |||
| 250 | 272 | <td class="c-subject"> | |
| 251 | 273 | <a href="/long/commit/SHA40">commit number 42</a> | |
| 252 | 274 | </td> | |
| 275 | + | <td class="c-ck"> | |
| 276 | + | </td> | |
| 253 | 277 | <td class="c-who dim">T</td> | |
| 254 | 278 | <td class="c-when nowrap dim"> | |
| 255 | 279 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -271,6 +295,8 @@ | |||
| 271 | 295 | <td class="c-subject"> | |
| 272 | 296 | <a href="/long/commit/SHA40">commit number 41</a> | |
| 273 | 297 | </td> | |
| 298 | + | <td class="c-ck"> | |
| 299 | + | </td> | |
| 274 | 300 | <td class="c-who dim">T</td> | |
| 275 | 301 | <td class="c-when nowrap dim"> | |
| 276 | 302 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -283,6 +309,8 @@ | |||
| 283 | 309 | <td class="c-subject"> | |
| 284 | 310 | <a href="/long/commit/SHA40">commit number 40</a> | |
| 285 | 311 | </td> | |
| 312 | + | <td class="c-ck"> | |
| 313 | + | </td> | |
| 286 | 314 | <td class="c-who dim">T</td> | |
| 287 | 315 | <td class="c-when nowrap dim"> | |
| 288 | 316 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -304,6 +332,8 @@ | |||
| 304 | 332 | <td class="c-subject"> | |
| 305 | 333 | <a href="/long/commit/SHA40">commit number 39</a> | |
| 306 | 334 | </td> | |
| 335 | + | <td class="c-ck"> | |
| 336 | + | </td> | |
| 307 | 337 | <td class="c-who dim">T</td> | |
| 308 | 338 | <td class="c-when nowrap dim"> | |
| 309 | 339 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -316,6 +346,8 @@ | |||
| 316 | 346 | <td class="c-subject"> | |
| 317 | 347 | <a href="/long/commit/SHA40">commit number 38</a> | |
| 318 | 348 | </td> | |
| 349 | + | <td class="c-ck"> | |
| 350 | + | </td> | |
| 319 | 351 | <td class="c-who dim">T</td> | |
| 320 | 352 | <td class="c-when nowrap dim"> | |
| 321 | 353 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -337,6 +369,8 @@ | |||
| 337 | 369 | <td class="c-subject"> | |
| 338 | 370 | <a href="/long/commit/SHA40">commit number 37</a> | |
| 339 | 371 | </td> | |
| 372 | + | <td class="c-ck"> | |
| 373 | + | </td> | |
| 340 | 374 | <td class="c-who dim">T</td> | |
| 341 | 375 | <td class="c-when nowrap dim"> | |
| 342 | 376 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -349,6 +383,8 @@ | |||
| 349 | 383 | <td class="c-subject"> | |
| 350 | 384 | <a href="/long/commit/SHA40">commit number 36</a> | |
| 351 | 385 | </td> | |
| 386 | + | <td class="c-ck"> | |
| 387 | + | </td> | |
| 352 | 388 | <td class="c-who dim">T</td> | |
| 353 | 389 | <td class="c-when nowrap dim"> | |
| 354 | 390 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -370,6 +406,8 @@ | |||
| 370 | 406 | <td class="c-subject"> | |
| 371 | 407 | <a href="/long/commit/SHA40">commit number 35</a> | |
| 372 | 408 | </td> | |
| 409 | + | <td class="c-ck"> | |
| 410 | + | </td> | |
| 373 | 411 | <td class="c-who dim">T</td> | |
| 374 | 412 | <td class="c-when nowrap dim"> | |
| 375 | 413 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -382,6 +420,8 @@ | |||
| 382 | 420 | <td class="c-subject"> | |
| 383 | 421 | <a href="/long/commit/SHA40">commit number 34</a> | |
| 384 | 422 | </td> | |
| 423 | + | <td class="c-ck"> | |
| 424 | + | </td> | |
| 385 | 425 | <td class="c-who dim">T</td> | |
| 386 | 426 | <td class="c-when nowrap dim"> | |
| 387 | 427 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -403,6 +443,8 @@ | |||
| 403 | 443 | <td class="c-subject"> | |
| 404 | 444 | <a href="/long/commit/SHA40">commit number 33</a> | |
| 405 | 445 | </td> | |
| 446 | + | <td class="c-ck"> | |
| 447 | + | </td> | |
| 406 | 448 | <td class="c-who dim">T</td> | |
| 407 | 449 | <td class="c-when nowrap dim"> | |
| 408 | 450 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -415,6 +457,8 @@ | |||
| 415 | 457 | <td class="c-subject"> | |
| 416 | 458 | <a href="/long/commit/SHA40">commit number 32</a> | |
| 417 | 459 | </td> | |
| 460 | + | <td class="c-ck"> | |
| 461 | + | </td> | |
| 418 | 462 | <td class="c-who dim">T</td> | |
| 419 | 463 | <td class="c-when nowrap dim"> | |
| 420 | 464 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -436,6 +480,8 @@ | |||
| 436 | 480 | <td class="c-subject"> | |
| 437 | 481 | <a href="/long/commit/SHA40">commit number 31</a> | |
| 438 | 482 | </td> | |
| 483 | + | <td class="c-ck"> | |
| 484 | + | </td> | |
| 439 | 485 | <td class="c-who dim">T</td> | |
| 440 | 486 | <td class="c-when nowrap dim"> | |
| 441 | 487 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -448,6 +494,8 @@ | |||
| 448 | 494 | <td class="c-subject"> | |
| 449 | 495 | <a href="/long/commit/SHA40">commit number 30</a> | |
| 450 | 496 | </td> | |
| 497 | + | <td class="c-ck"> | |
| 498 | + | </td> | |
| 451 | 499 | <td class="c-who dim">T</td> | |
| 452 | 500 | <td class="c-when nowrap dim"> | |
| 453 | 501 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -469,6 +517,8 @@ | |||
| 469 | 517 | <td class="c-subject"> | |
| 470 | 518 | <a href="/long/commit/SHA40">commit number 29</a> | |
| 471 | 519 | </td> | |
| 520 | + | <td class="c-ck"> | |
| 521 | + | </td> | |
| 472 | 522 | <td class="c-who dim">T</td> | |
| 473 | 523 | <td class="c-when nowrap dim"> | |
| 474 | 524 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -481,6 +531,8 @@ | |||
| 481 | 531 | <td class="c-subject"> | |
| 482 | 532 | <a href="/long/commit/SHA40">commit number 28</a> | |
| 483 | 533 | </td> | |
| 534 | + | <td class="c-ck"> | |
| 535 | + | </td> | |
| 484 | 536 | <td class="c-who dim">T</td> | |
| 485 | 537 | <td class="c-when nowrap dim"> | |
| 486 | 538 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -502,6 +554,8 @@ | |||
| 502 | 554 | <td class="c-subject"> | |
| 503 | 555 | <a href="/long/commit/SHA40">commit number 27</a> | |
| 504 | 556 | </td> | |
| 557 | + | <td class="c-ck"> | |
| 558 | + | </td> | |
| 505 | 559 | <td class="c-who dim">T</td> | |
| 506 | 560 | <td class="c-when nowrap dim"> | |
| 507 | 561 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -514,6 +568,8 @@ | |||
| 514 | 568 | <td class="c-subject"> | |
| 515 | 569 | <a href="/long/commit/SHA40">commit number 26</a> | |
| 516 | 570 | </td> | |
| 571 | + | <td class="c-ck"> | |
| 572 | + | </td> | |
| 517 | 573 | <td class="c-who dim">T</td> | |
| 518 | 574 | <td class="c-when nowrap dim"> | |
| 519 | 575 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -535,6 +591,8 @@ | |||
| 535 | 591 | <td class="c-subject"> | |
| 536 | 592 | <a href="/long/commit/SHA40">commit number 25</a> | |
| 537 | 593 | </td> | |
| 594 | + | <td class="c-ck"> | |
| 595 | + | </td> | |
| 538 | 596 | <td class="c-who dim">T</td> | |
| 539 | 597 | <td class="c-when nowrap dim"> | |
| 540 | 598 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -547,6 +605,8 @@ | |||
| 547 | 605 | <td class="c-subject"> | |
| 548 | 606 | <a href="/long/commit/SHA40">commit number 24</a> | |
| 549 | 607 | </td> | |
| 608 | + | <td class="c-ck"> | |
| 609 | + | </td> | |
| 550 | 610 | <td class="c-who dim">T</td> | |
| 551 | 611 | <td class="c-when nowrap dim"> | |
| 552 | 612 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -568,6 +628,8 @@ | |||
| 568 | 628 | <td class="c-subject"> | |
| 569 | 629 | <a href="/long/commit/SHA40">commit number 23</a> | |
| 570 | 630 | </td> | |
| 631 | + | <td class="c-ck"> | |
| 632 | + | </td> | |
| 571 | 633 | <td class="c-who dim">T</td> | |
| 572 | 634 | <td class="c-when nowrap dim"> | |
| 573 | 635 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -580,6 +642,8 @@ | |||
| 580 | 642 | <td class="c-subject"> | |
| 581 | 643 | <a href="/long/commit/SHA40">commit number 22</a> | |
| 582 | 644 | </td> | |
| 645 | + | <td class="c-ck"> | |
| 646 | + | </td> | |
| 583 | 647 | <td class="c-who dim">T</td> | |
| 584 | 648 | <td class="c-when nowrap dim"> | |
| 585 | 649 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -601,6 +665,8 @@ | |||
| 601 | 665 | <td class="c-subject"> | |
| 602 | 666 | <a href="/long/commit/SHA40">commit number 21</a> | |
| 603 | 667 | </td> | |
| 668 | + | <td class="c-ck"> | |
| 669 | + | </td> | |
| 604 | 670 | <td class="c-who dim">T</td> | |
| 605 | 671 | <td class="c-when nowrap dim"> | |
| 606 | 672 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -613,6 +679,8 @@ | |||
| 613 | 679 | <td class="c-subject"> | |
| 614 | 680 | <a href="/long/commit/SHA40">commit number 20</a> | |
| 615 | 681 | </td> | |
| 682 | + | <td class="c-ck"> | |
| 683 | + | </td> | |
| 616 | 684 | <td class="c-who dim">T</td> | |
| 617 | 685 | <td class="c-when nowrap dim"> | |
| 618 | 686 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -634,6 +702,8 @@ | |||
| 634 | 702 | <td class="c-subject"> | |
| 635 | 703 | <a href="/long/commit/SHA40">commit number 19</a> | |
| 636 | 704 | </td> | |
| 705 | + | <td class="c-ck"> | |
| 706 | + | </td> | |
| 637 | 707 | <td class="c-who dim">T</td> | |
| 638 | 708 | <td class="c-when nowrap dim"> | |
| 639 | 709 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -646,6 +716,8 @@ | |||
| 646 | 716 | <td class="c-subject"> | |
| 647 | 717 | <a href="/long/commit/SHA40">commit number 18</a> | |
| 648 | 718 | </td> | |
| 719 | + | <td class="c-ck"> | |
| 720 | + | </td> | |
| 649 | 721 | <td class="c-who dim">T</td> | |
| 650 | 722 | <td class="c-when nowrap dim"> | |
| 651 | 723 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -667,6 +739,8 @@ | |||
| 667 | 739 | <td class="c-subject"> | |
| 668 | 740 | <a href="/long/commit/SHA40">commit number 17</a> | |
| 669 | 741 | </td> | |
| 742 | + | <td class="c-ck"> | |
| 743 | + | </td> | |
| 670 | 744 | <td class="c-who dim">T</td> | |
| 671 | 745 | <td class="c-when nowrap dim"> | |
| 672 | 746 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -679,6 +753,8 @@ | |||
| 679 | 753 | <td class="c-subject"> | |
| 680 | 754 | <a href="/long/commit/SHA40">commit number 16</a> | |
| 681 | 755 | </td> | |
| 756 | + | <td class="c-ck"> | |
| 757 | + | </td> | |
| 682 | 758 | <td class="c-who dim">T</td> | |
| 683 | 759 | <td class="c-when nowrap dim"> | |
| 684 | 760 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -700,6 +776,8 @@ | |||
| 700 | 776 | <td class="c-subject"> | |
| 701 | 777 | <a href="/long/commit/SHA40">commit number 15</a> | |
| 702 | 778 | </td> | |
| 779 | + | <td class="c-ck"> | |
| 780 | + | </td> | |
| 703 | 781 | <td class="c-who dim">T</td> | |
| 704 | 782 | <td class="c-when nowrap dim"> | |
| 705 | 783 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -712,6 +790,8 @@ | |||
| 712 | 790 | <td class="c-subject"> | |
| 713 | 791 | <a href="/long/commit/SHA40">commit number 14</a> | |
| 714 | 792 | </td> | |
| 793 | + | <td class="c-ck"> | |
| 794 | + | </td> | |
| 715 | 795 | <td class="c-who dim">T</td> | |
| 716 | 796 | <td class="c-when nowrap dim"> | |
| 717 | 797 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -733,6 +813,8 @@ | |||
| 733 | 813 | <td class="c-subject"> | |
| 734 | 814 | <a href="/long/commit/SHA40">commit number 13</a> | |
| 735 | 815 | </td> | |
| 816 | + | <td class="c-ck"> | |
| 817 | + | </td> | |
| 736 | 818 | <td class="c-who dim">T</td> | |
| 737 | 819 | <td class="c-when nowrap dim"> | |
| 738 | 820 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -745,6 +827,8 @@ | |||
| 745 | 827 | <td class="c-subject"> | |
| 746 | 828 | <a href="/long/commit/SHA40">commit number 12</a> | |
| 747 | 829 | </td> | |
| 830 | + | <td class="c-ck"> | |
| 831 | + | </td> | |
| 748 | 832 | <td class="c-who dim">T</td> | |
| 749 | 833 | <td class="c-when nowrap dim"> | |
| 750 | 834 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -766,6 +850,8 @@ | |||
| 766 | 850 | <td class="c-subject"> | |
| 767 | 851 | <a href="/long/commit/SHA40">commit number 11</a> | |
| 768 | 852 | </td> | |
| 853 | + | <td class="c-ck"> | |
| 854 | + | </td> | |
| 769 | 855 | <td class="c-who dim">T</td> | |
| 770 | 856 | <td class="c-when nowrap dim"> | |
| 771 | 857 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -778,6 +864,8 @@ | |||
| 778 | 864 | <td class="c-subject"> | |
| 779 | 865 | <a href="/long/commit/SHA40">commit number 10</a> | |
| 780 | 866 | </td> | |
| 867 | + | <td class="c-ck"> | |
| 868 | + | </td> | |
| 781 | 869 | <td class="c-who dim">T</td> | |
| 782 | 870 | <td class="c-when nowrap dim"> | |
| 783 | 871 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -799,6 +887,8 @@ | |||
| 799 | 887 | <td class="c-subject"> | |
| 800 | 888 | <a href="/long/commit/SHA40">commit number 9</a> | |
| 801 | 889 | </td> | |
| 890 | + | <td class="c-ck"> | |
| 891 | + | </td> | |
| 802 | 892 | <td class="c-who dim">T</td> | |
| 803 | 893 | <td class="c-when nowrap dim"> | |
| 804 | 894 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -811,6 +901,8 @@ | |||
| 811 | 901 | <td class="c-subject"> | |
| 812 | 902 | <a href="/long/commit/SHA40">commit number 8</a> | |
| 813 | 903 | </td> | |
| 904 | + | <td class="c-ck"> | |
| 905 | + | </td> | |
| 814 | 906 | <td class="c-who dim">T</td> | |
| 815 | 907 | <td class="c-when nowrap dim"> | |
| 816 | 908 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -832,6 +924,8 @@ | |||
| 832 | 924 | <td class="c-subject"> | |
| 833 | 925 | <a href="/long/commit/SHA40">commit number 7</a> | |
| 834 | 926 | </td> | |
| 927 | + | <td class="c-ck"> | |
| 928 | + | </td> | |
| 835 | 929 | <td class="c-who dim">T</td> | |
| 836 | 930 | <td class="c-when nowrap dim"> | |
| 837 | 931 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -844,6 +938,8 @@ | |||
| 844 | 938 | <td class="c-subject"> | |
| 845 | 939 | <a href="/long/commit/SHA40">commit number 6</a> | |
| 846 | 940 | </td> | |
| 941 | + | <td class="c-ck"> | |
| 942 | + | </td> | |
| 847 | 943 | <td class="c-who dim">T</td> | |
| 848 | 944 | <td class="c-when nowrap dim"> | |
| 849 | 945 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -865,6 +961,8 @@ | |||
| 865 | 961 | <td class="c-subject"> | |
| 866 | 962 | <a href="/long/commit/SHA40">commit number 5</a> | |
| 867 | 963 | </td> | |
| 964 | + | <td class="c-ck"> | |
| 965 | + | </td> | |
| 868 | 966 | <td class="c-who dim">T</td> | |
| 869 | 967 | <td class="c-when nowrap dim"> | |
| 870 | 968 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -877,6 +975,8 @@ | |||
| 877 | 975 | <td class="c-subject"> | |
| 878 | 976 | <a href="/long/commit/SHA40">commit number 4</a> | |
| 879 | 977 | </td> | |
| 978 | + | <td class="c-ck"> | |
| 979 | + | </td> | |
| 880 | 980 | <td class="c-who dim">T</td> | |
| 881 | 981 | <td class="c-when nowrap dim"> | |
| 882 | 982 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -898,6 +998,8 @@ | |||
| 898 | 998 | <td class="c-subject"> | |
| 899 | 999 | <a href="/long/commit/SHA40">commit number 3</a> | |
| 900 | 1000 | </td> | |
| 1001 | + | <td class="c-ck"> | |
| 1002 | + | </td> | |
| 901 | 1003 | <td class="c-who dim">T</td> | |
| 902 | 1004 | <td class="c-when nowrap dim"> | |
| 903 | 1005 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -910,6 +1012,8 @@ | |||
| 910 | 1012 | <td class="c-subject"> | |
| 911 | 1013 | <a href="/long/commit/SHA40">commit number 2</a> | |
| 912 | 1014 | </td> | |
| 1015 | + | <td class="c-ck"> | |
| 1016 | + | </td> | |
| 913 | 1017 | <td class="c-who dim">T</td> | |
| 914 | 1018 | <td class="c-when nowrap dim"> | |
| 915 | 1019 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -959,7 +1063,7 @@ | |||
| 959 | 1063 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 960 | 1064 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 961 | 1065 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 962 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 1066 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 963 | 1067 | gap -> ermiteLive.reload()"> | |
| 964 | 1068 | </span> | |
| 965 | 1069 | </div> | |
| @@ -1131,6 +1235,12 @@ | |||
| 1131 | 1235 | <span class="k">section</span> | |
| 1132 | 1236 | </a> | |
| 1133 | 1237 | </li> | |
| 1238 | + | <li data-k="section actions"> | |
| 1239 | + | <a href="/long/actions" tabindex="-1"> | |
| 1240 | + | <span class="lbl">actions</span> | |
| 1241 | + | <span class="k">section</span> | |
| 1242 | + | </a> | |
| 1243 | + | </li> | |
| 1134 | 1244 | <li data-k="branch main"> | |
| 1135 | 1245 | <a href="/long/tree/main" tabindex="-1"> | |
| 1136 | 1246 | <span class="lbl">main</span> | |
| @@ -80,6 +80,10 @@ | |||||
| 80 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 80 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 81 | </a> | 81 | </a> | ||
| 82 | </li> | 82 | </li> | ||
| 83 | + | <li> | |||
| 84 | + | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 85 | + | </a> | |||
| 86 | + | </li> | |||
| 83 | </ul> | 87 | </ul> | ||
| 84 | </nav> | 88 | </nav> | ||
| 85 | </header> | 89 | </header> | ||
| @@ -106,6 +110,8 @@ | |||||
| 106 | <a href="/long/commit/SHA40">commit number 51</a> | 110 | <a href="/long/commit/SHA40">commit number 51</a> | ||
| 107 | <span class="pill">main</span> | 111 | <span class="pill">main</span> | ||
| 108 | </td> | 112 | </td> | ||
| 113 | + | <td class="c-ck"> | |||
| 114 | + | </td> | |||
| 109 | <td class="c-who dim">T</td> | 115 | <td class="c-who dim">T</td> | ||
| 110 | <td class="c-when nowrap dim"> | 116 | <td class="c-when nowrap dim"> | ||
| 111 | <time datetime="STAMP" title="STAMP">AGO</time> | 117 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -118,6 +124,8 @@ | |||||
| 118 | <td class="c-subject"> | 124 | <td class="c-subject"> | ||
| 119 | <a href="/long/commit/SHA40">commit number 50</a> | 125 | <a href="/long/commit/SHA40">commit number 50</a> | ||
| 120 | </td> | 126 | </td> | ||
| 127 | + | <td class="c-ck"> | |||
| 128 | + | </td> | |||
| 121 | <td class="c-who dim">T</td> | 129 | <td class="c-who dim">T</td> | ||
| 122 | <td class="c-when nowrap dim"> | 130 | <td class="c-when nowrap dim"> | ||
| 123 | <time datetime="STAMP" title="STAMP">AGO</time> | 131 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -139,6 +147,8 @@ | |||||
| 139 | <td class="c-subject"> | 147 | <td class="c-subject"> | ||
| 140 | <a href="/long/commit/SHA40">commit number 49</a> | 148 | <a href="/long/commit/SHA40">commit number 49</a> | ||
| 141 | </td> | 149 | </td> | ||
| 150 | + | <td class="c-ck"> | |||
| 151 | + | </td> | |||
| 142 | <td class="c-who dim">T</td> | 152 | <td class="c-who dim">T</td> | ||
| 143 | <td class="c-when nowrap dim"> | 153 | <td class="c-when nowrap dim"> | ||
| 144 | <time datetime="STAMP" title="STAMP">AGO</time> | 154 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -151,6 +161,8 @@ | |||||
| 151 | <td class="c-subject"> | 161 | <td class="c-subject"> | ||
| 152 | <a href="/long/commit/SHA40">commit number 48</a> | 162 | <a href="/long/commit/SHA40">commit number 48</a> | ||
| 153 | </td> | 163 | </td> | ||
| 164 | + | <td class="c-ck"> | |||
| 165 | + | </td> | |||
| 154 | <td class="c-who dim">T</td> | 166 | <td class="c-who dim">T</td> | ||
| 155 | <td class="c-when nowrap dim"> | 167 | <td class="c-when nowrap dim"> | ||
| 156 | <time datetime="STAMP" title="STAMP">AGO</time> | 168 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -172,6 +184,8 @@ | |||||
| 172 | <td class="c-subject"> | 184 | <td class="c-subject"> | ||
| 173 | <a href="/long/commit/SHA40">commit number 47</a> | 185 | <a href="/long/commit/SHA40">commit number 47</a> | ||
| 174 | </td> | 186 | </td> | ||
| 187 | + | <td class="c-ck"> | |||
| 188 | + | </td> | |||
| 175 | <td class="c-who dim">T</td> | 189 | <td class="c-who dim">T</td> | ||
| 176 | <td class="c-when nowrap dim"> | 190 | <td class="c-when nowrap dim"> | ||
| 177 | <time datetime="STAMP" title="STAMP">AGO</time> | 191 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -184,6 +198,8 @@ | |||||
| 184 | <td class="c-subject"> | 198 | <td class="c-subject"> | ||
| 185 | <a href="/long/commit/SHA40">commit number 46</a> | 199 | <a href="/long/commit/SHA40">commit number 46</a> | ||
| 186 | </td> | 200 | </td> | ||
| 201 | + | <td class="c-ck"> | |||
| 202 | + | </td> | |||
| 187 | <td class="c-who dim">T</td> | 203 | <td class="c-who dim">T</td> | ||
| 188 | <td class="c-when nowrap dim"> | 204 | <td class="c-when nowrap dim"> | ||
| 189 | <time datetime="STAMP" title="STAMP">AGO</time> | 205 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -205,6 +221,8 @@ | |||||
| 205 | <td class="c-subject"> | 221 | <td class="c-subject"> | ||
| 206 | <a href="/long/commit/SHA40">commit number 45</a> | 222 | <a href="/long/commit/SHA40">commit number 45</a> | ||
| 207 | </td> | 223 | </td> | ||
| 224 | + | <td class="c-ck"> | |||
| 225 | + | </td> | |||
| 208 | <td class="c-who dim">T</td> | 226 | <td class="c-who dim">T</td> | ||
| 209 | <td class="c-when nowrap dim"> | 227 | <td class="c-when nowrap dim"> | ||
| 210 | <time datetime="STAMP" title="STAMP">AGO</time> | 228 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -217,6 +235,8 @@ | |||||
| 217 | <td class="c-subject"> | 235 | <td class="c-subject"> | ||
| 218 | <a href="/long/commit/SHA40">commit number 44</a> | 236 | <a href="/long/commit/SHA40">commit number 44</a> | ||
| 219 | </td> | 237 | </td> | ||
| 238 | + | <td class="c-ck"> | |||
| 239 | + | </td> | |||
| 220 | <td class="c-who dim">T</td> | 240 | <td class="c-who dim">T</td> | ||
| 221 | <td class="c-when nowrap dim"> | 241 | <td class="c-when nowrap dim"> | ||
| 222 | <time datetime="STAMP" title="STAMP">AGO</time> | 242 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -238,6 +258,8 @@ | |||||
| 238 | <td class="c-subject"> | 258 | <td class="c-subject"> | ||
| 239 | <a href="/long/commit/SHA40">commit number 43</a> | 259 | <a href="/long/commit/SHA40">commit number 43</a> | ||
| 240 | </td> | 260 | </td> | ||
| 261 | + | <td class="c-ck"> | |||
| 262 | + | </td> | |||
| 241 | <td class="c-who dim">T</td> | 263 | <td class="c-who dim">T</td> | ||
| 242 | <td class="c-when nowrap dim"> | 264 | <td class="c-when nowrap dim"> | ||
| 243 | <time datetime="STAMP" title="STAMP">AGO</time> | 265 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -250,6 +272,8 @@ | |||||
| 250 | <td class="c-subject"> | 272 | <td class="c-subject"> | ||
| 251 | <a href="/long/commit/SHA40">commit number 42</a> | 273 | <a href="/long/commit/SHA40">commit number 42</a> | ||
| 252 | </td> | 274 | </td> | ||
| 275 | + | <td class="c-ck"> | |||
| 276 | + | </td> | |||
| 253 | <td class="c-who dim">T</td> | 277 | <td class="c-who dim">T</td> | ||
| 254 | <td class="c-when nowrap dim"> | 278 | <td class="c-when nowrap dim"> | ||
| 255 | <time datetime="STAMP" title="STAMP">AGO</time> | 279 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -271,6 +295,8 @@ | |||||
| 271 | <td class="c-subject"> | 295 | <td class="c-subject"> | ||
| 272 | <a href="/long/commit/SHA40">commit number 41</a> | 296 | <a href="/long/commit/SHA40">commit number 41</a> | ||
| 273 | </td> | 297 | </td> | ||
| 298 | + | <td class="c-ck"> | |||
| 299 | + | </td> | |||
| 274 | <td class="c-who dim">T</td> | 300 | <td class="c-who dim">T</td> | ||
| 275 | <td class="c-when nowrap dim"> | 301 | <td class="c-when nowrap dim"> | ||
| 276 | <time datetime="STAMP" title="STAMP">AGO</time> | 302 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -283,6 +309,8 @@ | |||||
| 283 | <td class="c-subject"> | 309 | <td class="c-subject"> | ||
| 284 | <a href="/long/commit/SHA40">commit number 40</a> | 310 | <a href="/long/commit/SHA40">commit number 40</a> | ||
| 285 | </td> | 311 | </td> | ||
| 312 | + | <td class="c-ck"> | |||
| 313 | + | </td> | |||
| 286 | <td class="c-who dim">T</td> | 314 | <td class="c-who dim">T</td> | ||
| 287 | <td class="c-when nowrap dim"> | 315 | <td class="c-when nowrap dim"> | ||
| 288 | <time datetime="STAMP" title="STAMP">AGO</time> | 316 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -304,6 +332,8 @@ | |||||
| 304 | <td class="c-subject"> | 332 | <td class="c-subject"> | ||
| 305 | <a href="/long/commit/SHA40">commit number 39</a> | 333 | <a href="/long/commit/SHA40">commit number 39</a> | ||
| 306 | </td> | 334 | </td> | ||
| 335 | + | <td class="c-ck"> | |||
| 336 | + | </td> | |||
| 307 | <td class="c-who dim">T</td> | 337 | <td class="c-who dim">T</td> | ||
| 308 | <td class="c-when nowrap dim"> | 338 | <td class="c-when nowrap dim"> | ||
| 309 | <time datetime="STAMP" title="STAMP">AGO</time> | 339 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -316,6 +346,8 @@ | |||||
| 316 | <td class="c-subject"> | 346 | <td class="c-subject"> | ||
| 317 | <a href="/long/commit/SHA40">commit number 38</a> | 347 | <a href="/long/commit/SHA40">commit number 38</a> | ||
| 318 | </td> | 348 | </td> | ||
| 349 | + | <td class="c-ck"> | |||
| 350 | + | </td> | |||
| 319 | <td class="c-who dim">T</td> | 351 | <td class="c-who dim">T</td> | ||
| 320 | <td class="c-when nowrap dim"> | 352 | <td class="c-when nowrap dim"> | ||
| 321 | <time datetime="STAMP" title="STAMP">AGO</time> | 353 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -337,6 +369,8 @@ | |||||
| 337 | <td class="c-subject"> | 369 | <td class="c-subject"> | ||
| 338 | <a href="/long/commit/SHA40">commit number 37</a> | 370 | <a href="/long/commit/SHA40">commit number 37</a> | ||
| 339 | </td> | 371 | </td> | ||
| 372 | + | <td class="c-ck"> | |||
| 373 | + | </td> | |||
| 340 | <td class="c-who dim">T</td> | 374 | <td class="c-who dim">T</td> | ||
| 341 | <td class="c-when nowrap dim"> | 375 | <td class="c-when nowrap dim"> | ||
| 342 | <time datetime="STAMP" title="STAMP">AGO</time> | 376 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -349,6 +383,8 @@ | |||||
| 349 | <td class="c-subject"> | 383 | <td class="c-subject"> | ||
| 350 | <a href="/long/commit/SHA40">commit number 36</a> | 384 | <a href="/long/commit/SHA40">commit number 36</a> | ||
| 351 | </td> | 385 | </td> | ||
| 386 | + | <td class="c-ck"> | |||
| 387 | + | </td> | |||
| 352 | <td class="c-who dim">T</td> | 388 | <td class="c-who dim">T</td> | ||
| 353 | <td class="c-when nowrap dim"> | 389 | <td class="c-when nowrap dim"> | ||
| 354 | <time datetime="STAMP" title="STAMP">AGO</time> | 390 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -370,6 +406,8 @@ | |||||
| 370 | <td class="c-subject"> | 406 | <td class="c-subject"> | ||
| 371 | <a href="/long/commit/SHA40">commit number 35</a> | 407 | <a href="/long/commit/SHA40">commit number 35</a> | ||
| 372 | </td> | 408 | </td> | ||
| 409 | + | <td class="c-ck"> | |||
| 410 | + | </td> | |||
| 373 | <td class="c-who dim">T</td> | 411 | <td class="c-who dim">T</td> | ||
| 374 | <td class="c-when nowrap dim"> | 412 | <td class="c-when nowrap dim"> | ||
| 375 | <time datetime="STAMP" title="STAMP">AGO</time> | 413 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -382,6 +420,8 @@ | |||||
| 382 | <td class="c-subject"> | 420 | <td class="c-subject"> | ||
| 383 | <a href="/long/commit/SHA40">commit number 34</a> | 421 | <a href="/long/commit/SHA40">commit number 34</a> | ||
| 384 | </td> | 422 | </td> | ||
| 423 | + | <td class="c-ck"> | |||
| 424 | + | </td> | |||
| 385 | <td class="c-who dim">T</td> | 425 | <td class="c-who dim">T</td> | ||
| 386 | <td class="c-when nowrap dim"> | 426 | <td class="c-when nowrap dim"> | ||
| 387 | <time datetime="STAMP" title="STAMP">AGO</time> | 427 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -403,6 +443,8 @@ | |||||
| 403 | <td class="c-subject"> | 443 | <td class="c-subject"> | ||
| 404 | <a href="/long/commit/SHA40">commit number 33</a> | 444 | <a href="/long/commit/SHA40">commit number 33</a> | ||
| 405 | </td> | 445 | </td> | ||
| 446 | + | <td class="c-ck"> | |||
| 447 | + | </td> | |||
| 406 | <td class="c-who dim">T</td> | 448 | <td class="c-who dim">T</td> | ||
| 407 | <td class="c-when nowrap dim"> | 449 | <td class="c-when nowrap dim"> | ||
| 408 | <time datetime="STAMP" title="STAMP">AGO</time> | 450 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -415,6 +457,8 @@ | |||||
| 415 | <td class="c-subject"> | 457 | <td class="c-subject"> | ||
| 416 | <a href="/long/commit/SHA40">commit number 32</a> | 458 | <a href="/long/commit/SHA40">commit number 32</a> | ||
| 417 | </td> | 459 | </td> | ||
| 460 | + | <td class="c-ck"> | |||
| 461 | + | </td> | |||
| 418 | <td class="c-who dim">T</td> | 462 | <td class="c-who dim">T</td> | ||
| 419 | <td class="c-when nowrap dim"> | 463 | <td class="c-when nowrap dim"> | ||
| 420 | <time datetime="STAMP" title="STAMP">AGO</time> | 464 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -436,6 +480,8 @@ | |||||
| 436 | <td class="c-subject"> | 480 | <td class="c-subject"> | ||
| 437 | <a href="/long/commit/SHA40">commit number 31</a> | 481 | <a href="/long/commit/SHA40">commit number 31</a> | ||
| 438 | </td> | 482 | </td> | ||
| 483 | + | <td class="c-ck"> | |||
| 484 | + | </td> | |||
| 439 | <td class="c-who dim">T</td> | 485 | <td class="c-who dim">T</td> | ||
| 440 | <td class="c-when nowrap dim"> | 486 | <td class="c-when nowrap dim"> | ||
| 441 | <time datetime="STAMP" title="STAMP">AGO</time> | 487 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -448,6 +494,8 @@ | |||||
| 448 | <td class="c-subject"> | 494 | <td class="c-subject"> | ||
| 449 | <a href="/long/commit/SHA40">commit number 30</a> | 495 | <a href="/long/commit/SHA40">commit number 30</a> | ||
| 450 | </td> | 496 | </td> | ||
| 497 | + | <td class="c-ck"> | |||
| 498 | + | </td> | |||
| 451 | <td class="c-who dim">T</td> | 499 | <td class="c-who dim">T</td> | ||
| 452 | <td class="c-when nowrap dim"> | 500 | <td class="c-when nowrap dim"> | ||
| 453 | <time datetime="STAMP" title="STAMP">AGO</time> | 501 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -469,6 +517,8 @@ | |||||
| 469 | <td class="c-subject"> | 517 | <td class="c-subject"> | ||
| 470 | <a href="/long/commit/SHA40">commit number 29</a> | 518 | <a href="/long/commit/SHA40">commit number 29</a> | ||
| 471 | </td> | 519 | </td> | ||
| 520 | + | <td class="c-ck"> | |||
| 521 | + | </td> | |||
| 472 | <td class="c-who dim">T</td> | 522 | <td class="c-who dim">T</td> | ||
| 473 | <td class="c-when nowrap dim"> | 523 | <td class="c-when nowrap dim"> | ||
| 474 | <time datetime="STAMP" title="STAMP">AGO</time> | 524 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -481,6 +531,8 @@ | |||||
| 481 | <td class="c-subject"> | 531 | <td class="c-subject"> | ||
| 482 | <a href="/long/commit/SHA40">commit number 28</a> | 532 | <a href="/long/commit/SHA40">commit number 28</a> | ||
| 483 | </td> | 533 | </td> | ||
| 534 | + | <td class="c-ck"> | |||
| 535 | + | </td> | |||
| 484 | <td class="c-who dim">T</td> | 536 | <td class="c-who dim">T</td> | ||
| 485 | <td class="c-when nowrap dim"> | 537 | <td class="c-when nowrap dim"> | ||
| 486 | <time datetime="STAMP" title="STAMP">AGO</time> | 538 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -502,6 +554,8 @@ | |||||
| 502 | <td class="c-subject"> | 554 | <td class="c-subject"> | ||
| 503 | <a href="/long/commit/SHA40">commit number 27</a> | 555 | <a href="/long/commit/SHA40">commit number 27</a> | ||
| 504 | </td> | 556 | </td> | ||
| 557 | + | <td class="c-ck"> | |||
| 558 | + | </td> | |||
| 505 | <td class="c-who dim">T</td> | 559 | <td class="c-who dim">T</td> | ||
| 506 | <td class="c-when nowrap dim"> | 560 | <td class="c-when nowrap dim"> | ||
| 507 | <time datetime="STAMP" title="STAMP">AGO</time> | 561 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -514,6 +568,8 @@ | |||||
| 514 | <td class="c-subject"> | 568 | <td class="c-subject"> | ||
| 515 | <a href="/long/commit/SHA40">commit number 26</a> | 569 | <a href="/long/commit/SHA40">commit number 26</a> | ||
| 516 | </td> | 570 | </td> | ||
| 571 | + | <td class="c-ck"> | |||
| 572 | + | </td> | |||
| 517 | <td class="c-who dim">T</td> | 573 | <td class="c-who dim">T</td> | ||
| 518 | <td class="c-when nowrap dim"> | 574 | <td class="c-when nowrap dim"> | ||
| 519 | <time datetime="STAMP" title="STAMP">AGO</time> | 575 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -535,6 +591,8 @@ | |||||
| 535 | <td class="c-subject"> | 591 | <td class="c-subject"> | ||
| 536 | <a href="/long/commit/SHA40">commit number 25</a> | 592 | <a href="/long/commit/SHA40">commit number 25</a> | ||
| 537 | </td> | 593 | </td> | ||
| 594 | + | <td class="c-ck"> | |||
| 595 | + | </td> | |||
| 538 | <td class="c-who dim">T</td> | 596 | <td class="c-who dim">T</td> | ||
| 539 | <td class="c-when nowrap dim"> | 597 | <td class="c-when nowrap dim"> | ||
| 540 | <time datetime="STAMP" title="STAMP">AGO</time> | 598 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -547,6 +605,8 @@ | |||||
| 547 | <td class="c-subject"> | 605 | <td class="c-subject"> | ||
| 548 | <a href="/long/commit/SHA40">commit number 24</a> | 606 | <a href="/long/commit/SHA40">commit number 24</a> | ||
| 549 | </td> | 607 | </td> | ||
| 608 | + | <td class="c-ck"> | |||
| 609 | + | </td> | |||
| 550 | <td class="c-who dim">T</td> | 610 | <td class="c-who dim">T</td> | ||
| 551 | <td class="c-when nowrap dim"> | 611 | <td class="c-when nowrap dim"> | ||
| 552 | <time datetime="STAMP" title="STAMP">AGO</time> | 612 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -568,6 +628,8 @@ | |||||
| 568 | <td class="c-subject"> | 628 | <td class="c-subject"> | ||
| 569 | <a href="/long/commit/SHA40">commit number 23</a> | 629 | <a href="/long/commit/SHA40">commit number 23</a> | ||
| 570 | </td> | 630 | </td> | ||
| 631 | + | <td class="c-ck"> | |||
| 632 | + | </td> | |||
| 571 | <td class="c-who dim">T</td> | 633 | <td class="c-who dim">T</td> | ||
| 572 | <td class="c-when nowrap dim"> | 634 | <td class="c-when nowrap dim"> | ||
| 573 | <time datetime="STAMP" title="STAMP">AGO</time> | 635 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -580,6 +642,8 @@ | |||||
| 580 | <td class="c-subject"> | 642 | <td class="c-subject"> | ||
| 581 | <a href="/long/commit/SHA40">commit number 22</a> | 643 | <a href="/long/commit/SHA40">commit number 22</a> | ||
| 582 | </td> | 644 | </td> | ||
| 645 | + | <td class="c-ck"> | |||
| 646 | + | </td> | |||
| 583 | <td class="c-who dim">T</td> | 647 | <td class="c-who dim">T</td> | ||
| 584 | <td class="c-when nowrap dim"> | 648 | <td class="c-when nowrap dim"> | ||
| 585 | <time datetime="STAMP" title="STAMP">AGO</time> | 649 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -601,6 +665,8 @@ | |||||
| 601 | <td class="c-subject"> | 665 | <td class="c-subject"> | ||
| 602 | <a href="/long/commit/SHA40">commit number 21</a> | 666 | <a href="/long/commit/SHA40">commit number 21</a> | ||
| 603 | </td> | 667 | </td> | ||
| 668 | + | <td class="c-ck"> | |||
| 669 | + | </td> | |||
| 604 | <td class="c-who dim">T</td> | 670 | <td class="c-who dim">T</td> | ||
| 605 | <td class="c-when nowrap dim"> | 671 | <td class="c-when nowrap dim"> | ||
| 606 | <time datetime="STAMP" title="STAMP">AGO</time> | 672 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -613,6 +679,8 @@ | |||||
| 613 | <td class="c-subject"> | 679 | <td class="c-subject"> | ||
| 614 | <a href="/long/commit/SHA40">commit number 20</a> | 680 | <a href="/long/commit/SHA40">commit number 20</a> | ||
| 615 | </td> | 681 | </td> | ||
| 682 | + | <td class="c-ck"> | |||
| 683 | + | </td> | |||
| 616 | <td class="c-who dim">T</td> | 684 | <td class="c-who dim">T</td> | ||
| 617 | <td class="c-when nowrap dim"> | 685 | <td class="c-when nowrap dim"> | ||
| 618 | <time datetime="STAMP" title="STAMP">AGO</time> | 686 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -634,6 +702,8 @@ | |||||
| 634 | <td class="c-subject"> | 702 | <td class="c-subject"> | ||
| 635 | <a href="/long/commit/SHA40">commit number 19</a> | 703 | <a href="/long/commit/SHA40">commit number 19</a> | ||
| 636 | </td> | 704 | </td> | ||
| 705 | + | <td class="c-ck"> | |||
| 706 | + | </td> | |||
| 637 | <td class="c-who dim">T</td> | 707 | <td class="c-who dim">T</td> | ||
| 638 | <td class="c-when nowrap dim"> | 708 | <td class="c-when nowrap dim"> | ||
| 639 | <time datetime="STAMP" title="STAMP">AGO</time> | 709 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -646,6 +716,8 @@ | |||||
| 646 | <td class="c-subject"> | 716 | <td class="c-subject"> | ||
| 647 | <a href="/long/commit/SHA40">commit number 18</a> | 717 | <a href="/long/commit/SHA40">commit number 18</a> | ||
| 648 | </td> | 718 | </td> | ||
| 719 | + | <td class="c-ck"> | |||
| 720 | + | </td> | |||
| 649 | <td class="c-who dim">T</td> | 721 | <td class="c-who dim">T</td> | ||
| 650 | <td class="c-when nowrap dim"> | 722 | <td class="c-when nowrap dim"> | ||
| 651 | <time datetime="STAMP" title="STAMP">AGO</time> | 723 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -667,6 +739,8 @@ | |||||
| 667 | <td class="c-subject"> | 739 | <td class="c-subject"> | ||
| 668 | <a href="/long/commit/SHA40">commit number 17</a> | 740 | <a href="/long/commit/SHA40">commit number 17</a> | ||
| 669 | </td> | 741 | </td> | ||
| 742 | + | <td class="c-ck"> | |||
| 743 | + | </td> | |||
| 670 | <td class="c-who dim">T</td> | 744 | <td class="c-who dim">T</td> | ||
| 671 | <td class="c-when nowrap dim"> | 745 | <td class="c-when nowrap dim"> | ||
| 672 | <time datetime="STAMP" title="STAMP">AGO</time> | 746 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -679,6 +753,8 @@ | |||||
| 679 | <td class="c-subject"> | 753 | <td class="c-subject"> | ||
| 680 | <a href="/long/commit/SHA40">commit number 16</a> | 754 | <a href="/long/commit/SHA40">commit number 16</a> | ||
| 681 | </td> | 755 | </td> | ||
| 756 | + | <td class="c-ck"> | |||
| 757 | + | </td> | |||
| 682 | <td class="c-who dim">T</td> | 758 | <td class="c-who dim">T</td> | ||
| 683 | <td class="c-when nowrap dim"> | 759 | <td class="c-when nowrap dim"> | ||
| 684 | <time datetime="STAMP" title="STAMP">AGO</time> | 760 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -700,6 +776,8 @@ | |||||
| 700 | <td class="c-subject"> | 776 | <td class="c-subject"> | ||
| 701 | <a href="/long/commit/SHA40">commit number 15</a> | 777 | <a href="/long/commit/SHA40">commit number 15</a> | ||
| 702 | </td> | 778 | </td> | ||
| 779 | + | <td class="c-ck"> | |||
| 780 | + | </td> | |||
| 703 | <td class="c-who dim">T</td> | 781 | <td class="c-who dim">T</td> | ||
| 704 | <td class="c-when nowrap dim"> | 782 | <td class="c-when nowrap dim"> | ||
| 705 | <time datetime="STAMP" title="STAMP">AGO</time> | 783 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -712,6 +790,8 @@ | |||||
| 712 | <td class="c-subject"> | 790 | <td class="c-subject"> | ||
| 713 | <a href="/long/commit/SHA40">commit number 14</a> | 791 | <a href="/long/commit/SHA40">commit number 14</a> | ||
| 714 | </td> | 792 | </td> | ||
| 793 | + | <td class="c-ck"> | |||
| 794 | + | </td> | |||
| 715 | <td class="c-who dim">T</td> | 795 | <td class="c-who dim">T</td> | ||
| 716 | <td class="c-when nowrap dim"> | 796 | <td class="c-when nowrap dim"> | ||
| 717 | <time datetime="STAMP" title="STAMP">AGO</time> | 797 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -733,6 +813,8 @@ | |||||
| 733 | <td class="c-subject"> | 813 | <td class="c-subject"> | ||
| 734 | <a href="/long/commit/SHA40">commit number 13</a> | 814 | <a href="/long/commit/SHA40">commit number 13</a> | ||
| 735 | </td> | 815 | </td> | ||
| 816 | + | <td class="c-ck"> | |||
| 817 | + | </td> | |||
| 736 | <td class="c-who dim">T</td> | 818 | <td class="c-who dim">T</td> | ||
| 737 | <td class="c-when nowrap dim"> | 819 | <td class="c-when nowrap dim"> | ||
| 738 | <time datetime="STAMP" title="STAMP">AGO</time> | 820 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -745,6 +827,8 @@ | |||||
| 745 | <td class="c-subject"> | 827 | <td class="c-subject"> | ||
| 746 | <a href="/long/commit/SHA40">commit number 12</a> | 828 | <a href="/long/commit/SHA40">commit number 12</a> | ||
| 747 | </td> | 829 | </td> | ||
| 830 | + | <td class="c-ck"> | |||
| 831 | + | </td> | |||
| 748 | <td class="c-who dim">T</td> | 832 | <td class="c-who dim">T</td> | ||
| 749 | <td class="c-when nowrap dim"> | 833 | <td class="c-when nowrap dim"> | ||
| 750 | <time datetime="STAMP" title="STAMP">AGO</time> | 834 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -766,6 +850,8 @@ | |||||
| 766 | <td class="c-subject"> | 850 | <td class="c-subject"> | ||
| 767 | <a href="/long/commit/SHA40">commit number 11</a> | 851 | <a href="/long/commit/SHA40">commit number 11</a> | ||
| 768 | </td> | 852 | </td> | ||
| 853 | + | <td class="c-ck"> | |||
| 854 | + | </td> | |||
| 769 | <td class="c-who dim">T</td> | 855 | <td class="c-who dim">T</td> | ||
| 770 | <td class="c-when nowrap dim"> | 856 | <td class="c-when nowrap dim"> | ||
| 771 | <time datetime="STAMP" title="STAMP">AGO</time> | 857 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -778,6 +864,8 @@ | |||||
| 778 | <td class="c-subject"> | 864 | <td class="c-subject"> | ||
| 779 | <a href="/long/commit/SHA40">commit number 10</a> | 865 | <a href="/long/commit/SHA40">commit number 10</a> | ||
| 780 | </td> | 866 | </td> | ||
| 867 | + | <td class="c-ck"> | |||
| 868 | + | </td> | |||
| 781 | <td class="c-who dim">T</td> | 869 | <td class="c-who dim">T</td> | ||
| 782 | <td class="c-when nowrap dim"> | 870 | <td class="c-when nowrap dim"> | ||
| 783 | <time datetime="STAMP" title="STAMP">AGO</time> | 871 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -799,6 +887,8 @@ | |||||
| 799 | <td class="c-subject"> | 887 | <td class="c-subject"> | ||
| 800 | <a href="/long/commit/SHA40">commit number 9</a> | 888 | <a href="/long/commit/SHA40">commit number 9</a> | ||
| 801 | </td> | 889 | </td> | ||
| 890 | + | <td class="c-ck"> | |||
| 891 | + | </td> | |||
| 802 | <td class="c-who dim">T</td> | 892 | <td class="c-who dim">T</td> | ||
| 803 | <td class="c-when nowrap dim"> | 893 | <td class="c-when nowrap dim"> | ||
| 804 | <time datetime="STAMP" title="STAMP">AGO</time> | 894 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -811,6 +901,8 @@ | |||||
| 811 | <td class="c-subject"> | 901 | <td class="c-subject"> | ||
| 812 | <a href="/long/commit/SHA40">commit number 8</a> | 902 | <a href="/long/commit/SHA40">commit number 8</a> | ||
| 813 | </td> | 903 | </td> | ||
| 904 | + | <td class="c-ck"> | |||
| 905 | + | </td> | |||
| 814 | <td class="c-who dim">T</td> | 906 | <td class="c-who dim">T</td> | ||
| 815 | <td class="c-when nowrap dim"> | 907 | <td class="c-when nowrap dim"> | ||
| 816 | <time datetime="STAMP" title="STAMP">AGO</time> | 908 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -832,6 +924,8 @@ | |||||
| 832 | <td class="c-subject"> | 924 | <td class="c-subject"> | ||
| 833 | <a href="/long/commit/SHA40">commit number 7</a> | 925 | <a href="/long/commit/SHA40">commit number 7</a> | ||
| 834 | </td> | 926 | </td> | ||
| 927 | + | <td class="c-ck"> | |||
| 928 | + | </td> | |||
| 835 | <td class="c-who dim">T</td> | 929 | <td class="c-who dim">T</td> | ||
| 836 | <td class="c-when nowrap dim"> | 930 | <td class="c-when nowrap dim"> | ||
| 837 | <time datetime="STAMP" title="STAMP">AGO</time> | 931 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -844,6 +938,8 @@ | |||||
| 844 | <td class="c-subject"> | 938 | <td class="c-subject"> | ||
| 845 | <a href="/long/commit/SHA40">commit number 6</a> | 939 | <a href="/long/commit/SHA40">commit number 6</a> | ||
| 846 | </td> | 940 | </td> | ||
| 941 | + | <td class="c-ck"> | |||
| 942 | + | </td> | |||
| 847 | <td class="c-who dim">T</td> | 943 | <td class="c-who dim">T</td> | ||
| 848 | <td class="c-when nowrap dim"> | 944 | <td class="c-when nowrap dim"> | ||
| 849 | <time datetime="STAMP" title="STAMP">AGO</time> | 945 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -865,6 +961,8 @@ | |||||
| 865 | <td class="c-subject"> | 961 | <td class="c-subject"> | ||
| 866 | <a href="/long/commit/SHA40">commit number 5</a> | 962 | <a href="/long/commit/SHA40">commit number 5</a> | ||
| 867 | </td> | 963 | </td> | ||
| 964 | + | <td class="c-ck"> | |||
| 965 | + | </td> | |||
| 868 | <td class="c-who dim">T</td> | 966 | <td class="c-who dim">T</td> | ||
| 869 | <td class="c-when nowrap dim"> | 967 | <td class="c-when nowrap dim"> | ||
| 870 | <time datetime="STAMP" title="STAMP">AGO</time> | 968 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -877,6 +975,8 @@ | |||||
| 877 | <td class="c-subject"> | 975 | <td class="c-subject"> | ||
| 878 | <a href="/long/commit/SHA40">commit number 4</a> | 976 | <a href="/long/commit/SHA40">commit number 4</a> | ||
| 879 | </td> | 977 | </td> | ||
| 978 | + | <td class="c-ck"> | |||
| 979 | + | </td> | |||
| 880 | <td class="c-who dim">T</td> | 980 | <td class="c-who dim">T</td> | ||
| 881 | <td class="c-when nowrap dim"> | 981 | <td class="c-when nowrap dim"> | ||
| 882 | <time datetime="STAMP" title="STAMP">AGO</time> | 982 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -898,6 +998,8 @@ | |||||
| 898 | <td class="c-subject"> | 998 | <td class="c-subject"> | ||
| 899 | <a href="/long/commit/SHA40">commit number 3</a> | 999 | <a href="/long/commit/SHA40">commit number 3</a> | ||
| 900 | </td> | 1000 | </td> | ||
| 1001 | + | <td class="c-ck"> | |||
| 1002 | + | </td> | |||
| 901 | <td class="c-who dim">T</td> | 1003 | <td class="c-who dim">T</td> | ||
| 902 | <td class="c-when nowrap dim"> | 1004 | <td class="c-when nowrap dim"> | ||
| 903 | <time datetime="STAMP" title="STAMP">AGO</time> | 1005 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -910,6 +1012,8 @@ | |||||
| 910 | <td class="c-subject"> | 1012 | <td class="c-subject"> | ||
| 911 | <a href="/long/commit/SHA40">commit number 2</a> | 1013 | <a href="/long/commit/SHA40">commit number 2</a> | ||
| 912 | </td> | 1014 | </td> | ||
| 1015 | + | <td class="c-ck"> | |||
| 1016 | + | </td> | |||
| 913 | <td class="c-who dim">T</td> | 1017 | <td class="c-who dim">T</td> | ||
| 914 | <td class="c-when nowrap dim"> | 1018 | <td class="c-when nowrap dim"> | ||
| 915 | <time datetime="STAMP" title="STAMP">AGO</time> | 1019 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -959,7 +1063,7 @@ | |||||
| 959 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | 1063 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | ||
| 960 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 1064 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 961 | htmx:sse:before:connection -> ermiteLive.gate(event); | 1065 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 962 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | 1066 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); |
| 963 | gap -> ermiteLive.reload()"> | 1067 | gap -> ermiteLive.reload()"> | ||
| 964 | </span> | 1068 | </span> | ||
| 965 | </div> | 1069 | </div> | ||
| @@ -1131,6 +1235,12 @@ | |||||
| 1131 | <span class="k">section</span> | 1235 | <span class="k">section</span> | ||
| 1132 | </a> | 1236 | </a> | ||
| 1133 | </li> | 1237 | </li> | ||
| 1238 | + | <li data-k="section actions"> | |||
| 1239 | + | <a href="/long/actions" tabindex="-1"> | |||
| 1240 | + | <span class="lbl">actions</span> | |||
| 1241 | + | <span class="k">section</span> | |||
| 1242 | + | </a> | |||
| 1243 | + | </li> | |||
| 1134 | <li data-k="branch main"> | 1244 | <li data-k="branch main"> | ||
| 1135 | <a href="/long/tree/main" tabindex="-1"> | 1245 | <a href="/long/tree/main" tabindex="-1"> | ||
| 1136 | <span class="lbl">main</span> | 1246 | <span class="lbl">main</span> | ||
M internal/app/testdata/web/commits-small.html +27 -1
| @@ -80,6 +80,10 @@ | |||
| 80 | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 81 | </a> | |
| 82 | 82 | </li> | |
| 83 | + | <li> | |
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 85 | + | </a> | |
| 86 | + | </li> | |
| 83 | 87 | </ul> | |
| 84 | 88 | </nav> | |
| 85 | 89 | </header> | |
| @@ -106,6 +110,8 @@ | |||
| 106 | 110 | <a href="/diffs/commit/SHA40">add a very large file</a> | |
| 107 | 111 | <span class="pill">main</span> | |
| 108 | 112 | </td> | |
| 113 | + | <td class="c-ck"> | |
| 114 | + | </td> | |
| 109 | 115 | <td class="c-who dim">T</td> | |
| 110 | 116 | <td class="c-when nowrap dim"> | |
| 111 | 117 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -127,6 +133,8 @@ | |||
| 127 | 133 | <td class="c-subject"> | |
| 128 | 134 | <a href="/diffs/commit/SHA40">add evil.go</a> | |
| 129 | 135 | </td> | |
| 136 | + | <td class="c-ck"> | |
| 137 | + | </td> | |
| 130 | 138 | <td class="c-who dim">T</td> | |
| 131 | 139 | <td class="c-when nowrap dim"> | |
| 132 | 140 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -149,6 +157,8 @@ | |||
| 149 | 157 | <a href="/diffs/commit/SHA40">merge the side branch</a> | |
| 150 | 158 | <span class="pill">merge</span> | |
| 151 | 159 | </td> | |
| 160 | + | <td class="c-ck"> | |
| 161 | + | </td> | |
| 152 | 162 | <td class="c-who dim">T</td> | |
| 153 | 163 | <td class="c-when nowrap dim"> | |
| 154 | 164 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -171,6 +181,8 @@ | |||
| 171 | 181 | <a href="/diffs/commit/SHA40">work on the side</a> | |
| 172 | 182 | <span class="pill">side</span> | |
| 173 | 183 | </td> | |
| 184 | + | <td class="c-ck"> | |
| 185 | + | </td> | |
| 174 | 186 | <td class="c-who dim">T</td> | |
| 175 | 187 | <td class="c-when nowrap dim"> | |
| 176 | 188 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -192,6 +204,8 @@ | |||
| 192 | 204 | <td class="c-subject"> | |
| 193 | 205 | <a href="/diffs/commit/SHA40">change a binary file</a> | |
| 194 | 206 | </td> | |
| 207 | + | <td class="c-ck"> | |
| 208 | + | </td> | |
| 195 | 209 | <td class="c-who dim">T</td> | |
| 196 | 210 | <td class="c-when nowrap dim"> | |
| 197 | 211 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -213,6 +227,8 @@ | |||
| 213 | 227 | <td class="c-subject"> | |
| 214 | 228 | <a href="/diffs/commit/SHA40">rename a file and delete another</a> | |
| 215 | 229 | </td> | |
| 230 | + | <td class="c-ck"> | |
| 231 | + | </td> | |
| 216 | 232 | <td class="c-who dim">T</td> | |
| 217 | 233 | <td class="c-when nowrap dim"> | |
| 218 | 234 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -236,6 +252,8 @@ | |||
| 236 | 252 | <span class="pill tag"> | |
| 237 | 253 | <span class="sr">tag </span>v1.0.0</span> | |
| 238 | 254 | </td> | |
| 255 | + | <td class="c-ck"> | |
| 256 | + | </td> | |
| 239 | 257 | <td class="c-who dim">T</td> | |
| 240 | 258 | <td class="c-when nowrap dim"> | |
| 241 | 259 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -257,6 +275,8 @@ | |||
| 257 | 275 | <td class="c-subject"> | |
| 258 | 276 | <a href="/diffs/commit/SHA40">root: the first files</a> | |
| 259 | 277 | </td> | |
| 278 | + | <td class="c-ck"> | |
| 279 | + | </td> | |
| 260 | 280 | <td class="c-who dim">T</td> | |
| 261 | 281 | <td class="c-when nowrap dim"> | |
| 262 | 282 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -304,7 +324,7 @@ | |||
| 304 | 324 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 305 | 325 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 306 | 326 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 307 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 327 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 308 | 328 | gap -> ermiteLive.reload()"> | |
| 309 | 329 | </span> | |
| 310 | 330 | </div> | |
| @@ -484,6 +504,12 @@ | |||
| 484 | 504 | <span class="k">section</span> | |
| 485 | 505 | </a> | |
| 486 | 506 | </li> | |
| 507 | + | <li data-k="section actions"> | |
| 508 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 509 | + | <span class="lbl">actions</span> | |
| 510 | + | <span class="k">section</span> | |
| 511 | + | </a> | |
| 512 | + | </li> | |
| 487 | 513 | <li data-k="branch main"> | |
| 488 | 514 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 489 | 515 | <span class="lbl">main</span> | |
| @@ -80,6 +80,10 @@ | |||||
| 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 80 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 81 | </a> | 81 | </a> | ||
| 82 | </li> | 82 | </li> | ||
| 83 | + | <li> | |||
| 84 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 85 | + | </a> | |||
| 86 | + | </li> | |||
| 83 | </ul> | 87 | </ul> | ||
| 84 | </nav> | 88 | </nav> | ||
| 85 | </header> | 89 | </header> | ||
| @@ -106,6 +110,8 @@ | |||||
| 106 | <a href="/diffs/commit/SHA40">add a very large file</a> | 110 | <a href="/diffs/commit/SHA40">add a very large file</a> | ||
| 107 | <span class="pill">main</span> | 111 | <span class="pill">main</span> | ||
| 108 | </td> | 112 | </td> | ||
| 113 | + | <td class="c-ck"> | |||
| 114 | + | </td> | |||
| 109 | <td class="c-who dim">T</td> | 115 | <td class="c-who dim">T</td> | ||
| 110 | <td class="c-when nowrap dim"> | 116 | <td class="c-when nowrap dim"> | ||
| 111 | <time datetime="STAMP" title="STAMP">AGO</time> | 117 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -127,6 +133,8 @@ | |||||
| 127 | <td class="c-subject"> | 133 | <td class="c-subject"> | ||
| 128 | <a href="/diffs/commit/SHA40">add evil.go</a> | 134 | <a href="/diffs/commit/SHA40">add evil.go</a> | ||
| 129 | </td> | 135 | </td> | ||
| 136 | + | <td class="c-ck"> | |||
| 137 | + | </td> | |||
| 130 | <td class="c-who dim">T</td> | 138 | <td class="c-who dim">T</td> | ||
| 131 | <td class="c-when nowrap dim"> | 139 | <td class="c-when nowrap dim"> | ||
| 132 | <time datetime="STAMP" title="STAMP">AGO</time> | 140 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -149,6 +157,8 @@ | |||||
| 149 | <a href="/diffs/commit/SHA40">merge the side branch</a> | 157 | <a href="/diffs/commit/SHA40">merge the side branch</a> | ||
| 150 | <span class="pill">merge</span> | 158 | <span class="pill">merge</span> | ||
| 151 | </td> | 159 | </td> | ||
| 160 | + | <td class="c-ck"> | |||
| 161 | + | </td> | |||
| 152 | <td class="c-who dim">T</td> | 162 | <td class="c-who dim">T</td> | ||
| 153 | <td class="c-when nowrap dim"> | 163 | <td class="c-when nowrap dim"> | ||
| 154 | <time datetime="STAMP" title="STAMP">AGO</time> | 164 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -171,6 +181,8 @@ | |||||
| 171 | <a href="/diffs/commit/SHA40">work on the side</a> | 181 | <a href="/diffs/commit/SHA40">work on the side</a> | ||
| 172 | <span class="pill">side</span> | 182 | <span class="pill">side</span> | ||
| 173 | </td> | 183 | </td> | ||
| 184 | + | <td class="c-ck"> | |||
| 185 | + | </td> | |||
| 174 | <td class="c-who dim">T</td> | 186 | <td class="c-who dim">T</td> | ||
| 175 | <td class="c-when nowrap dim"> | 187 | <td class="c-when nowrap dim"> | ||
| 176 | <time datetime="STAMP" title="STAMP">AGO</time> | 188 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -192,6 +204,8 @@ | |||||
| 192 | <td class="c-subject"> | 204 | <td class="c-subject"> | ||
| 193 | <a href="/diffs/commit/SHA40">change a binary file</a> | 205 | <a href="/diffs/commit/SHA40">change a binary file</a> | ||
| 194 | </td> | 206 | </td> | ||
| 207 | + | <td class="c-ck"> | |||
| 208 | + | </td> | |||
| 195 | <td class="c-who dim">T</td> | 209 | <td class="c-who dim">T</td> | ||
| 196 | <td class="c-when nowrap dim"> | 210 | <td class="c-when nowrap dim"> | ||
| 197 | <time datetime="STAMP" title="STAMP">AGO</time> | 211 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -213,6 +227,8 @@ | |||||
| 213 | <td class="c-subject"> | 227 | <td class="c-subject"> | ||
| 214 | <a href="/diffs/commit/SHA40">rename a file and delete another</a> | 228 | <a href="/diffs/commit/SHA40">rename a file and delete another</a> | ||
| 215 | </td> | 229 | </td> | ||
| 230 | + | <td class="c-ck"> | |||
| 231 | + | </td> | |||
| 216 | <td class="c-who dim">T</td> | 232 | <td class="c-who dim">T</td> | ||
| 217 | <td class="c-when nowrap dim"> | 233 | <td class="c-when nowrap dim"> | ||
| 218 | <time datetime="STAMP" title="STAMP">AGO</time> | 234 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -236,6 +252,8 @@ | |||||
| 236 | <span class="pill tag"> | 252 | <span class="pill tag"> | ||
| 237 | <span class="sr">tag </span>v1.0.0</span> | 253 | <span class="sr">tag </span>v1.0.0</span> | ||
| 238 | </td> | 254 | </td> | ||
| 255 | + | <td class="c-ck"> | |||
| 256 | + | </td> | |||
| 239 | <td class="c-who dim">T</td> | 257 | <td class="c-who dim">T</td> | ||
| 240 | <td class="c-when nowrap dim"> | 258 | <td class="c-when nowrap dim"> | ||
| 241 | <time datetime="STAMP" title="STAMP">AGO</time> | 259 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -257,6 +275,8 @@ | |||||
| 257 | <td class="c-subject"> | 275 | <td class="c-subject"> | ||
| 258 | <a href="/diffs/commit/SHA40">root: the first files</a> | 276 | <a href="/diffs/commit/SHA40">root: the first files</a> | ||
| 259 | </td> | 277 | </td> | ||
| 278 | + | <td class="c-ck"> | |||
| 279 | + | </td> | |||
| 260 | <td class="c-who dim">T</td> | 280 | <td class="c-who dim">T</td> | ||
| 261 | <td class="c-when nowrap dim"> | 281 | <td class="c-when nowrap dim"> | ||
| 262 | <time datetime="STAMP" title="STAMP">AGO</time> | 282 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -304,7 +324,7 @@ | |||||
| 304 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | 324 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | ||
| 305 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 325 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 306 | htmx:sse:before:connection -> ermiteLive.gate(event); | 326 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 307 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | 327 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); |
| 308 | gap -> ermiteLive.reload()"> | 328 | gap -> ermiteLive.reload()"> | ||
| 309 | </span> | 329 | </span> | ||
| 310 | </div> | 330 | </div> | ||
| @@ -484,6 +504,12 @@ | |||||
| 484 | <span class="k">section</span> | 504 | <span class="k">section</span> | ||
| 485 | </a> | 505 | </a> | ||
| 486 | </li> | 506 | </li> | ||
| 507 | + | <li data-k="section actions"> | |||
| 508 | + | <a href="/diffs/actions" tabindex="-1"> | |||
| 509 | + | <span class="lbl">actions</span> | |||
| 510 | + | <span class="k">section</span> | |||
| 511 | + | </a> | |||
| 512 | + | </li> | |||
| 487 | <li data-k="branch main"> | 513 | <li data-k="branch main"> | ||
| 488 | <a href="/diffs/tree/main" tabindex="-1"> | 514 | <a href="/diffs/tree/main" tabindex="-1"> | ||
| 489 | <span class="lbl">main</span> | 515 | <span class="lbl">main</span> | ||
M internal/app/testdata/web/home.html +1 -1
| @@ -113,7 +113,7 @@ | |||
| 113 | 113 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 114 | 114 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 115 | 115 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 116 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 116 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 117 | 117 | gap -> ermiteLive.reload()"> | |
| 118 | 118 | </span> | |
| 119 | 119 | </div> | |
| @@ -113,7 +113,7 @@ | |||||
| 113 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | 113 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | ||
| 114 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 114 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 115 | htmx:sse:before:connection -> ermiteLive.gate(event); | 115 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 116 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | 116 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); |
| 117 | gap -> ermiteLive.reload()"> | 117 | gap -> ermiteLive.reload()"> | ||
| 118 | </span> | 118 | </span> | ||
| 119 | </div> | 119 | </div> | ||
M internal/app/testdata/web/refs.html +11 -1
| @@ -77,6 +77,10 @@ | |||
| 77 | 77 | <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 78 | 78 | </a> | |
| 79 | 79 | </li> | |
| 80 | + | <li> | |
| 81 | + | <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 82 | + | </a> | |
| 83 | + | </li> | |
| 80 | 84 | </ul> | |
| 81 | 85 | </nav> | |
| 82 | 86 | </header> | |
| @@ -196,7 +200,7 @@ | |||
| 196 | 200 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 197 | 201 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 198 | 202 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 199 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 203 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 200 | 204 | gap -> ermiteLive.reload()"> | |
| 201 | 205 | </span> | |
| 202 | 206 | </div> | |
| @@ -380,6 +384,12 @@ | |||
| 380 | 384 | <span class="k">section</span> | |
| 381 | 385 | </a> | |
| 382 | 386 | </li> | |
| 387 | + | <li data-k="section actions"> | |
| 388 | + | <a href="/demo/actions" tabindex="-1"> | |
| 389 | + | <span class="lbl">actions</span> | |
| 390 | + | <span class="k">section</span> | |
| 391 | + | </a> | |
| 392 | + | </li> | |
| 383 | 393 | <li data-k="branch feature/x"> | |
| 384 | 394 | <a href="/demo/tree/feature/x" tabindex="-1"> | |
| 385 | 395 | <span class="lbl">feature/x</span> | |
| @@ -77,6 +77,10 @@ | |||||
| 77 | <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 77 | <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 78 | </a> | 78 | </a> | ||
| 79 | </li> | 79 | </li> | ||
| 80 | + | <li> | |||
| 81 | + | <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 82 | + | </a> | |||
| 83 | + | </li> | |||
| 80 | </ul> | 84 | </ul> | ||
| 81 | </nav> | 85 | </nav> | ||
| 82 | </header> | 86 | </header> | ||
| @@ -196,7 +200,7 @@ | |||||
| 196 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | 200 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | ||
| 197 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 201 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 198 | htmx:sse:before:connection -> ermiteLive.gate(event); | 202 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 199 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | 203 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); |
| 200 | gap -> ermiteLive.reload()"> | 204 | gap -> ermiteLive.reload()"> | ||
| 201 | </span> | 205 | </span> | ||
| 202 | </div> | 206 | </div> | ||
| @@ -380,6 +384,12 @@ | |||||
| 380 | <span class="k">section</span> | 384 | <span class="k">section</span> | ||
| 381 | </a> | 385 | </a> | ||
| 382 | </li> | 386 | </li> | ||
| 387 | + | <li data-k="section actions"> | |||
| 388 | + | <a href="/demo/actions" tabindex="-1"> | |||
| 389 | + | <span class="lbl">actions</span> | |||
| 390 | + | <span class="k">section</span> | |||
| 391 | + | </a> | |||
| 392 | + | </li> | |||
| 383 | <li data-k="branch feature/x"> | 393 | <li data-k="branch feature/x"> | ||
| 384 | <a href="/demo/tree/feature/x" tabindex="-1"> | 394 | <a href="/demo/tree/feature/x" tabindex="-1"> | ||
| 385 | <span class="lbl">feature/x</span> | 395 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/summary.html +15 -1
| @@ -74,6 +74,10 @@ | |||
| 74 | 74 | <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 75 | 75 | </a> | |
| 76 | 76 | </li> | |
| 77 | + | <li> | |
| 78 | + | <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 79 | + | </a> | |
| 80 | + | </li> | |
| 77 | 81 | </ul> | |
| 78 | 82 | </nav> | |
| 79 | 83 | </header> | |
| @@ -166,6 +170,8 @@ | |||
| 166 | 170 | <span class="pill tag"> | |
| 167 | 171 | <span class="sr">tag </span>v0.2.0</span> | |
| 168 | 172 | </td> | |
| 173 | + | <td class="c-ck"> | |
| 174 | + | </td> | |
| 169 | 175 | <td class="c-who dim">T</td> | |
| 170 | 176 | <td class="c-when nowrap dim"> | |
| 171 | 177 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -180,6 +186,8 @@ | |||
| 180 | 186 | <span class="pill tag"> | |
| 181 | 187 | <span class="sr">tag </span>v0.1.0</span> | |
| 182 | 188 | </td> | |
| 189 | + | <td class="c-ck"> | |
| 190 | + | </td> | |
| 183 | 191 | <td class="c-who dim">T</td> | |
| 184 | 192 | <td class="c-when nowrap dim"> | |
| 185 | 193 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -215,7 +223,7 @@ | |||
| 215 | 223 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 216 | 224 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 217 | 225 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 218 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 226 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 219 | 227 | gap -> ermiteLive.reload()"> | |
| 220 | 228 | </span> | |
| 221 | 229 | </div> | |
| @@ -399,6 +407,12 @@ | |||
| 399 | 407 | <span class="k">section</span> | |
| 400 | 408 | </a> | |
| 401 | 409 | </li> | |
| 410 | + | <li data-k="section actions"> | |
| 411 | + | <a href="/demo/actions" tabindex="-1"> | |
| 412 | + | <span class="lbl">actions</span> | |
| 413 | + | <span class="k">section</span> | |
| 414 | + | </a> | |
| 415 | + | </li> | |
| 402 | 416 | <li data-k="branch feature/x"> | |
| 403 | 417 | <a href="/demo/tree/feature/x" tabindex="-1"> | |
| 404 | 418 | <span class="lbl">feature/x</span> | |
| @@ -74,6 +74,10 @@ | |||||
| 74 | <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 74 | <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 75 | </a> | 75 | </a> | ||
| 76 | </li> | 76 | </li> | ||
| 77 | + | <li> | |||
| 78 | + | <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 79 | + | </a> | |||
| 80 | + | </li> | |||
| 77 | </ul> | 81 | </ul> | ||
| 78 | </nav> | 82 | </nav> | ||
| 79 | </header> | 83 | </header> | ||
| @@ -166,6 +170,8 @@ | |||||
| 166 | <span class="pill tag"> | 170 | <span class="pill tag"> | ||
| 167 | <span class="sr">tag </span>v0.2.0</span> | 171 | <span class="sr">tag </span>v0.2.0</span> | ||
| 168 | </td> | 172 | </td> | ||
| 173 | + | <td class="c-ck"> | |||
| 174 | + | </td> | |||
| 169 | <td class="c-who dim">T</td> | 175 | <td class="c-who dim">T</td> | ||
| 170 | <td class="c-when nowrap dim"> | 176 | <td class="c-when nowrap dim"> | ||
| 171 | <time datetime="STAMP" title="STAMP">AGO</time> | 177 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -180,6 +186,8 @@ | |||||
| 180 | <span class="pill tag"> | 186 | <span class="pill tag"> | ||
| 181 | <span class="sr">tag </span>v0.1.0</span> | 187 | <span class="sr">tag </span>v0.1.0</span> | ||
| 182 | </td> | 188 | </td> | ||
| 189 | + | <td class="c-ck"> | |||
| 190 | + | </td> | |||
| 183 | <td class="c-who dim">T</td> | 191 | <td class="c-who dim">T</td> | ||
| 184 | <td class="c-when nowrap dim"> | 192 | <td class="c-when nowrap dim"> | ||
| 185 | <time datetime="STAMP" title="STAMP">AGO</time> | 193 | <time datetime="STAMP" title="STAMP">AGO</time> | ||
| @@ -215,7 +223,7 @@ | |||||
| 215 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | 223 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | ||
| 216 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 224 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 217 | htmx:sse:before:connection -> ermiteLive.gate(event); | 225 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 218 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | 226 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); |
| 219 | gap -> ermiteLive.reload()"> | 227 | gap -> ermiteLive.reload()"> | ||
| 220 | </span> | 228 | </span> | ||
| 221 | </div> | 229 | </div> | ||
| @@ -399,6 +407,12 @@ | |||||
| 399 | <span class="k">section</span> | 407 | <span class="k">section</span> | ||
| 400 | </a> | 408 | </a> | ||
| 401 | </li> | 409 | </li> | ||
| 410 | + | <li data-k="section actions"> | |||
| 411 | + | <a href="/demo/actions" tabindex="-1"> | |||
| 412 | + | <span class="lbl">actions</span> | |||
| 413 | + | <span class="k">section</span> | |||
| 414 | + | </a> | |||
| 415 | + | </li> | |||
| 402 | <li data-k="branch feature/x"> | 416 | <li data-k="branch feature/x"> | ||
| 403 | <a href="/demo/tree/feature/x" tabindex="-1"> | 417 | <a href="/demo/tree/feature/x" tabindex="-1"> | ||
| 404 | <span class="lbl">feature/x</span> | 418 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/tree-nested.html +10 -0
| @@ -86,6 +86,10 @@ | |||
| 86 | 86 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 87 | 87 | </a> | |
| 88 | 88 | </li> | |
| 89 | + | <li> | |
| 90 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 91 | + | </a> | |
| 92 | + | </li> | |
| 89 | 93 | </ul> | |
| 90 | 94 | </nav> | |
| 91 | 95 | </header> | |
| @@ -328,6 +332,12 @@ | |||
| 328 | 332 | <span class="k">section</span> | |
| 329 | 333 | </a> | |
| 330 | 334 | </li> | |
| 335 | + | <li data-k="section actions"> | |
| 336 | + | <a href="/files/actions" tabindex="-1"> | |
| 337 | + | <span class="lbl">actions</span> | |
| 338 | + | <span class="k">section</span> | |
| 339 | + | </a> | |
| 340 | + | </li> | |
| 331 | 341 | <li data-k="branch feature/x"> | |
| 332 | 342 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 333 | 343 | <span class="lbl">feature/x</span> | |
| @@ -86,6 +86,10 @@ | |||||
| 86 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 86 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 87 | </a> | 87 | </a> | ||
| 88 | </li> | 88 | </li> | ||
| 89 | + | <li> | |||
| 90 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 91 | + | </a> | |||
| 92 | + | </li> | |||
| 89 | </ul> | 93 | </ul> | ||
| 90 | </nav> | 94 | </nav> | ||
| 91 | </header> | 95 | </header> | ||
| @@ -328,6 +332,12 @@ | |||||
| 328 | <span class="k">section</span> | 332 | <span class="k">section</span> | ||
| 329 | </a> | 333 | </a> | ||
| 330 | </li> | 334 | </li> | ||
| 335 | + | <li data-k="section actions"> | |||
| 336 | + | <a href="/files/actions" tabindex="-1"> | |||
| 337 | + | <span class="lbl">actions</span> | |||
| 338 | + | <span class="k">section</span> | |||
| 339 | + | </a> | |||
| 340 | + | </li> | |||
| 331 | <li data-k="branch feature/x"> | 341 | <li data-k="branch feature/x"> | ||
| 332 | <a href="/files/tree/feature/x" tabindex="-1"> | 342 | <a href="/files/tree/feature/x" tabindex="-1"> | ||
| 333 | <span class="lbl">feature/x</span> | 343 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/tree-root.html +10 -0
| @@ -80,6 +80,10 @@ | |||
| 80 | 80 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 81 | </a> | |
| 82 | 82 | </li> | |
| 83 | + | <li> | |
| 84 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 85 | + | </a> | |
| 86 | + | </li> | |
| 83 | 87 | </ul> | |
| 84 | 88 | </nav> | |
| 85 | 89 | </header> | |
| @@ -555,6 +559,12 @@ | |||
| 555 | 559 | <span class="k">section</span> | |
| 556 | 560 | </a> | |
| 557 | 561 | </li> | |
| 562 | + | <li data-k="section actions"> | |
| 563 | + | <a href="/files/actions" tabindex="-1"> | |
| 564 | + | <span class="lbl">actions</span> | |
| 565 | + | <span class="k">section</span> | |
| 566 | + | </a> | |
| 567 | + | </li> | |
| 558 | 568 | <li data-k="branch feature/x"> | |
| 559 | 569 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 560 | 570 | <span class="lbl">feature/x</span> | |
| @@ -80,6 +80,10 @@ | |||||
| 80 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 80 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 81 | </a> | 81 | </a> | ||
| 82 | </li> | 82 | </li> | ||
| 83 | + | <li> | |||
| 84 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 85 | + | </a> | |||
| 86 | + | </li> | |||
| 83 | </ul> | 87 | </ul> | ||
| 84 | </nav> | 88 | </nav> | ||
| 85 | </header> | 89 | </header> | ||
| @@ -555,6 +559,12 @@ | |||||
| 555 | <span class="k">section</span> | 559 | <span class="k">section</span> | ||
| 556 | </a> | 560 | </a> | ||
| 557 | </li> | 561 | </li> | ||
| 562 | + | <li data-k="section actions"> | |||
| 563 | + | <a href="/files/actions" tabindex="-1"> | |||
| 564 | + | <span class="lbl">actions</span> | |||
| 565 | + | <span class="k">section</span> | |||
| 566 | + | </a> | |||
| 567 | + | </li> | |||
| 558 | <li data-k="branch feature/x"> | 568 | <li data-k="branch feature/x"> | ||
| 559 | <a href="/files/tree/feature/x" tabindex="-1"> | 569 | <a href="/files/tree/feature/x" tabindex="-1"> | ||
| 560 | <span class="lbl">feature/x</span> | 570 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/tree-slash-ref.html +10 -0
| @@ -83,6 +83,10 @@ | |||
| 83 | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 84 | </a> | |
| 85 | 85 | </li> | |
| 86 | + | <li> | |
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 88 | + | </a> | |
| 89 | + | </li> | |
| 86 | 90 | </ul> | |
| 87 | 91 | </nav> | |
| 88 | 92 | </header> | |
| @@ -325,6 +329,12 @@ | |||
| 325 | 329 | <span class="k">section</span> | |
| 326 | 330 | </a> | |
| 327 | 331 | </li> | |
| 332 | + | <li data-k="section actions"> | |
| 333 | + | <a href="/files/actions" tabindex="-1"> | |
| 334 | + | <span class="lbl">actions</span> | |
| 335 | + | <span class="k">section</span> | |
| 336 | + | </a> | |
| 337 | + | </li> | |
| 328 | 338 | <li data-k="branch feature/x"> | |
| 329 | 339 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 330 | 340 | <span class="lbl">feature/x</span> | |
| @@ -83,6 +83,10 @@ | |||||
| 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 84 | </a> | 84 | </a> | ||
| 85 | </li> | 85 | </li> | ||
| 86 | + | <li> | |||
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 88 | + | </a> | |||
| 89 | + | </li> | |||
| 86 | </ul> | 90 | </ul> | ||
| 87 | </nav> | 91 | </nav> | ||
| 88 | </header> | 92 | </header> | ||
| @@ -325,6 +329,12 @@ | |||||
| 325 | <span class="k">section</span> | 329 | <span class="k">section</span> | ||
| 326 | </a> | 330 | </a> | ||
| 327 | </li> | 331 | </li> | ||
| 332 | + | <li data-k="section actions"> | |||
| 333 | + | <a href="/files/actions" tabindex="-1"> | |||
| 334 | + | <span class="lbl">actions</span> | |||
| 335 | + | <span class="k">section</span> | |||
| 336 | + | </a> | |||
| 337 | + | </li> | |||
| 328 | <li data-k="branch feature/x"> | 338 | <li data-k="branch feature/x"> | ||
| 329 | <a href="/files/tree/feature/x" tabindex="-1"> | 339 | <a href="/files/tree/feature/x" tabindex="-1"> | ||
| 330 | <span class="lbl">feature/x</span> | 340 | <span class="lbl">feature/x</span> | ||
M internal/app/testdata/web/tree-subdir.html +10 -0
| @@ -83,6 +83,10 @@ | |||
| 83 | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 84 | </a> | |
| 85 | 85 | </li> | |
| 86 | + | <li> | |
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 88 | + | </a> | |
| 89 | + | </li> | |
| 86 | 90 | </ul> | |
| 87 | 91 | </nav> | |
| 88 | 92 | </header> | |
| @@ -350,6 +354,12 @@ | |||
| 350 | 354 | <span class="k">section</span> | |
| 351 | 355 | </a> | |
| 352 | 356 | </li> | |
| 357 | + | <li data-k="section actions"> | |
| 358 | + | <a href="/files/actions" tabindex="-1"> | |
| 359 | + | <span class="lbl">actions</span> | |
| 360 | + | <span class="k">section</span> | |
| 361 | + | </a> | |
| 362 | + | </li> | |
| 353 | 363 | <li data-k="branch feature/x"> | |
| 354 | 364 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 355 | 365 | <span class="lbl">feature/x</span> | |
| @@ -83,6 +83,10 @@ | |||||
| 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | 83 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | ||
| 84 | </a> | 84 | </a> | ||
| 85 | </li> | 85 | </li> | ||
| 86 | + | <li> | |||
| 87 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |||
| 88 | + | </a> | |||
| 89 | + | </li> | |||
| 86 | </ul> | 90 | </ul> | ||
| 87 | </nav> | 91 | </nav> | ||
| 88 | </header> | 92 | </header> | ||
| @@ -350,6 +354,12 @@ | |||||
| 350 | <span class="k">section</span> | 354 | <span class="k">section</span> | ||
| 351 | </a> | 355 | </a> | ||
| 352 | </li> | 356 | </li> | ||
| 357 | + | <li data-k="section actions"> | |||
| 358 | + | <a href="/files/actions" tabindex="-1"> | |||
| 359 | + | <span class="lbl">actions</span> | |||
| 360 | + | <span class="k">section</span> | |||
| 361 | + | </a> | |||
| 362 | + | </li> | |||
| 353 | <li data-k="branch feature/x"> | 363 | <li data-k="branch feature/x"> | ||
| 354 | <a href="/files/tree/feature/x" tabindex="-1"> | 364 | <a href="/files/tree/feature/x" tabindex="-1"> | ||
| 355 | <span class="lbl">feature/x</span> | 365 | <span class="lbl">feature/x</span> | ||
M internal/app/web.go +1 -0
| @@ -33,6 +33,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||
| 33 | 33 | Repos: f.Repos, | |
| 34 | 34 | Browse: f.Browse, | |
| 35 | 35 | MergeRequests: f.MergeRequests, | |
| 36 | + | Runs: f.CI, | |
| 36 | 37 | Events: f.EventLog, | |
| 37 | 38 | PublicURL: cfg.PublicURL, | |
| 38 | 39 | SourceURL: cfg.SourceURL, | |
| @@ -33,6 +33,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||||
| 33 | Repos: f.Repos, | 33 | Repos: f.Repos, | ||
| 34 | Browse: f.Browse, | 34 | Browse: f.Browse, | ||
| 35 | MergeRequests: f.MergeRequests, | 35 | MergeRequests: f.MergeRequests, | ||
| 36 | + | Runs: f.CI, | |||
| 36 | Events: f.EventLog, | 37 | Events: f.EventLog, | ||
| 37 | PublicURL: cfg.PublicURL, | 38 | PublicURL: cfg.PublicURL, | ||
| 38 | SourceURL: cfg.SourceURL, | 39 | SourceURL: cfg.SourceURL, | ||