Commit 3af6fd1aaf
Verified · cmc ci/build: success ci/test: success
e2e/mr_test.go +8 −3
| @@ -18,10 +18,15 @@ type mrShow struct { | ||
| 18 | 18 | Source string `json:"source"` |
| 19 | 19 | TargetRef string `json:"target_ref"` |
| 20 | 20 | HeadSHA string `json:"head_sha"` |
| 21 | MergedAt string `json:"merged_at"` | |
| 22 | MergedBy string `json:"merged_by"` | |
| 23 | ClosedAt string `json:"closed_at"` | |
| 24 | ClosedBy string `json:"closed_by"` | |
| 21 | 25 | Reviews []struct { |
| 22 | Reviewer string `json:"reviewer"` | |
| 23 | Verdict string `json:"verdict"` | |
| 24 | Stale bool `json:"stale"` | |
| 26 | Reviewer string `json:"reviewer"` | |
| 27 | Verdict string `json:"verdict"` | |
| 28 | Stale bool `json:"stale"` | |
| 29 | CreatedAt string `json:"created_at"` | |
| 25 | 30 | } `json:"reviews"` |
| 26 | 31 | } |
| 27 | 32 | |
e2e/mrweb_test.go +17 −2
| @@ -133,8 +133,23 @@ func TestMRWebReviewLoop(t *testing.T) { | ||
| 133 | 133 | if status, _ := browserPost(t, alice, mrURL+"/merge", url.Values{"strategy": {"auto"}}); status != 200 { |
| 134 | 134 | t.Fatalf("merge post: %d", status) |
| 135 | 135 | } |
| 136 | if st := inst.mrShow(t, aliceKey, "alice/lib", "1").State; st != "merged" { | |
| 137 | t.Fatalf("MR state after web merge: %s", st) | |
| 136 | merged := inst.mrShow(t, aliceKey, "alice/lib", "1") | |
| 137 | if merged.State != "merged" { | |
| 138 | t.Fatalf("MR state after web merge: %s", merged.State) | |
| 139 | } | |
| 140 | // Who merged it and when, so the page can stop saying alice wants to. | |
| 141 | if merged.MergedBy != "alice" || merged.MergedAt == "" { | |
| 142 | t.Fatalf("merge not attributed: %+v", merged) | |
| 143 | } | |
| 144 | if merged.Reviews[0].CreatedAt == "" { | |
| 145 | t.Fatalf("review carries no timestamp: %+v", merged.Reviews[0]) | |
| 146 | } | |
| 147 | _, body = browserGet(t, alice, mrURL) | |
| 148 | if strings.Contains(body, "wants to merge") { | |
| 149 | t.Fatalf("merged MR still wants to merge:\n%s", body) | |
| 150 | } | |
| 151 | if !strings.Contains(body, ">alice</a> merged") { | |
| 152 | t.Fatalf("merged MR does not name the merger:\n%s", body) | |
| 138 | 153 | } |
| 139 | 154 | mustGit(t, dir, env, "pull", "-q", "origin", "main") |
| 140 | 155 | if _, err := os.Stat(filepath.Join(dir, "feature.txt")); err != nil { |
internal/control/ghimport.go +3 −2
| @@ -35,6 +35,7 @@ type ghIssue struct { | ||
| 35 | 35 | Body string `json:"body"` |
| 36 | 36 | State string `json:"state"` |
| 37 | 37 | CreatedAt string `json:"created_at"` |
| 38 | ClosedAt string `json:"closed_at"` | |
| 38 | 39 | User ghUser `json:"user"` |
| 39 | 40 | Labels []struct{ Name string } `json:"labels"` |
| 40 | 41 | PullRequest *struct{} `json:"pull_request"` |
| @@ -194,9 +195,9 @@ func runImportIssues(c *Ctx, args []string) int { | ||
| 194 | 195 | return c.fail(protocol.ExitFailure, "%v", err) |
| 195 | 196 | } |
| 196 | 197 | if pr.MergedAt != "" { |
| 197 | c.Store.MarkMerged(mr.ID, pr.Base.SHA) | |
| 198 | c.Store.MarkMerged(mr.ID, pr.Base.SHA, 0, pr.MergedAt) | |
| 198 | 199 | } else { |
| 199 | c.Store.SetMRState(mr.ID, "closed") | |
| 200 | c.Store.MarkClosed(mr.ID, 0, it.ClosedAt) | |
| 200 | 201 | } |
| 201 | 202 | // Point the MR head ref at the PR head when the mirror |
| 202 | 203 | // already holds the objects (refs/pull backups). |
internal/control/mr.go +46 −15
| @@ -332,6 +332,10 @@ type mrOut struct { | ||
| 332 | 332 | BodyFormat string `json:"body_format,omitempty"` |
| 333 | 333 | Milestone string `json:"milestone,omitempty"` |
| 334 | 334 | CreatedAt string `json:"created_at"` |
| 335 | MergedAt string `json:"merged_at,omitempty"` | |
| 336 | MergedBy string `json:"merged_by,omitempty"` | |
| 337 | ClosedAt string `json:"closed_at,omitempty"` | |
| 338 | ClosedBy string `json:"closed_by,omitempty"` | |
| 335 | 339 | } |
| 336 | 340 | |
| 337 | 341 | func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut { |
| @@ -345,7 +349,8 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut { | ||
| 345 | 349 | } |
| 346 | 350 | o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author, |
| 347 | 351 | Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone, |
| 348 | CreatedAt: m.CreatedAt} | |
| 352 | CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy, | |
| 353 | ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy} | |
| 349 | 354 | if withBody { |
| 350 | 355 | o.Body = m.Body |
| 351 | 356 | o.BodyFormat = m.BodyFormat |
| @@ -401,6 +406,15 @@ func runMRList(c *Ctx, args []string) int { | ||
| 401 | 406 | }) |
| 402 | 407 | } |
| 403 | 408 | |
| 409 | // byWhom renders " by <user>", or nothing when the actor is unknown — an | |
| 410 | // imported merge request carries a time but no local account. | |
| 411 | func byWhom(user string) string { | |
| 412 | if user == "" { | |
| 413 | return "" | |
| 414 | } | |
| 415 | return " by " + user | |
| 416 | } | |
| 417 | ||
| 404 | 418 | func runMRShow(c *Ctx, args []string) int { |
| 405 | 419 | repo, mr, code := mrRef(c, args, policy.CanRead) |
| 406 | 420 | if code >= 0 { |
| @@ -417,7 +431,7 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 417 | 431 | if err != nil { |
| 418 | 432 | return c.fail(protocol.ExitFailure, "%v", err) |
| 419 | 433 | } |
| 420 | statuses, err := c.Store.ListCommitStatuses(repo.ID, mr.HeadSHA) | |
| 434 | statuses, combined, err := c.Store.ChecksForCommit(repo.ID, mr.HeadSHA) | |
| 421 | 435 | if err != nil { |
| 422 | 436 | return c.fail(protocol.ExitFailure, "%v", err) |
| 423 | 437 | } |
| @@ -432,18 +446,25 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 432 | 446 | CreatedAt string `json:"created_at"` |
| 433 | 447 | } |
| 434 | 448 | type reviewOut struct { |
| 435 | Reviewer string `json:"reviewer"` | |
| 436 | Verdict string `json:"verdict"` | |
| 437 | Stale bool `json:"stale"` | |
| 449 | Reviewer string `json:"reviewer"` | |
| 450 | Verdict string `json:"verdict"` | |
| 451 | Stale bool `json:"stale"` | |
| 452 | CreatedAt string `json:"created_at"` | |
| 438 | 453 | } |
| 439 | 454 | type checkOut struct { |
| 440 | Context string `json:"context"` | |
| 441 | State string `json:"state"` | |
| 442 | URL string `json:"url,omitempty"` | |
| 455 | Context string `json:"context"` | |
| 456 | State string `json:"state"` | |
| 457 | URL string `json:"url,omitempty"` | |
| 458 | UpdatedAt string `json:"updated_at"` | |
| 459 | Duration string `json:"duration,omitempty"` // CI checks only, once finished | |
| 443 | 460 | } |
| 444 | 461 | var checks []checkOut |
| 445 | 462 | for _, st := range statuses { |
| 446 | checks = append(checks, checkOut{st.Context, st.State, st.TargetURL}) | |
| 463 | out := checkOut{Context: st.Context, State: st.State, URL: st.TargetURL, UpdatedAt: st.UpdatedAt} | |
| 464 | if st.Duration > 0 { | |
| 465 | out.Duration = st.Duration.String() | |
| 466 | } | |
| 467 | checks = append(checks, out) | |
| 447 | 468 | } |
| 448 | 469 | var cs []commentOut |
| 449 | 470 | for _, cm := range comments { |
| @@ -451,7 +472,7 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 451 | 472 | } |
| 452 | 473 | var rs []reviewOut |
| 453 | 474 | for _, r := range reviews { |
| 454 | rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale}) | |
| 475 | rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale, r.CreatedAt}) | |
| 455 | 476 | } |
| 456 | 477 | // The commits this MR carries: base..head, the diff's range. |
| 457 | 478 | type commitOut struct { |
| @@ -487,9 +508,15 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 487 | 508 | Commits []commitOut `json:"commits,omitempty"` |
| 488 | 509 | Comments []commentOut `json:"comments,omitempty"` |
| 489 | 510 | Reviews []reviewOut `json:"reviews,omitempty"` |
| 490 | }{mrToOut(repo, mr, true), checks, store.CombinedStatus(statuses), unresolved, commits, cs, rs} | |
| 511 | }{mrToOut(repo, mr, true), checks, combined, unresolved, commits, cs, rs} | |
| 491 | 512 | return c.emit(d, func(w io.Writer) { |
| 492 | 513 | fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, d.State, d.Author, d.Source, d.TargetRef, d.HeadSHA) |
| 514 | if d.MergedAt != "" { | |
| 515 | fmt.Fprintf(w, "merged %s%s\n", d.MergedAt, byWhom(d.MergedBy)) | |
| 516 | } | |
| 517 | if d.ClosedAt != "" { | |
| 518 | fmt.Fprintf(w, "closed %s%s\n", d.ClosedAt, byWhom(d.ClosedBy)) | |
| 519 | } | |
| 493 | 520 | if d.Body != "" { |
| 494 | 521 | fmt.Fprintf(w, "\n%s\n", d.Body) |
| 495 | 522 | } |
| @@ -497,7 +524,11 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 497 | 524 | fmt.Fprintf(w, "commit: %.10s %s\n", cm.SHA, cm.Subject) |
| 498 | 525 | } |
| 499 | 526 | for _, x := range checks { |
| 500 | fmt.Fprintf(w, "check: %s %s\n", x.Context, x.State) | |
| 527 | dur := "" | |
| 528 | if x.Duration != "" { | |
| 529 | dur = " in " + x.Duration | |
| 530 | } | |
| 531 | fmt.Fprintf(w, "check: %s %s at %s%s\n", x.Context, x.State, x.UpdatedAt, dur) | |
| 501 | 532 | } |
| 502 | 533 | if d.UnresolvedThreads > 0 { |
| 503 | 534 | fmt.Fprintf(w, "unresolved threads: %d\n", d.UnresolvedThreads) |
| @@ -507,7 +538,7 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 507 | 538 | if r.Stale { |
| 508 | 539 | stale = " (stale)" |
| 509 | 540 | } |
| 510 | fmt.Fprintf(w, "review: %s %s%s\n", r.Reviewer, r.Verdict, stale) | |
| 541 | fmt.Fprintf(w, "review: %s %s%s at %s\n", r.Reviewer, r.Verdict, stale, r.CreatedAt) | |
| 511 | 542 | } |
| 512 | 543 | for _, cm := range cs { |
| 513 | 544 | fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body) |
| @@ -980,7 +1011,7 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 980 | 1011 | if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil { |
| 981 | 1012 | return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err) |
| 982 | 1013 | } |
| 983 | if err := c.Store.MarkMerged(mr.ID, targetSHA); err != nil { | |
| 1014 | if err := c.Store.MarkMerged(mr.ID, targetSHA, c.User.ID, ""); err != nil { | |
| 984 | 1015 | return c.fail(protocol.ExitFailure, "%v", err) |
| 985 | 1016 | } |
| 986 | 1017 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA)) |
| @@ -1131,7 +1162,7 @@ func runMRClose(c *Ctx, args []string) int { | ||
| 1131 | 1162 | if mr.State == "merged" || mr.State == "closed" { |
| 1132 | 1163 | return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State) |
| 1133 | 1164 | } |
| 1134 | if err := c.Store.SetMRState(mr.ID, "closed"); err != nil { | |
| 1165 | if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil { | |
| 1135 | 1166 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1136 | 1167 | } |
| 1137 | 1168 | return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) { |
internal/httpd/mrpage_test.go added +104
| @@ -0,0 +1,104 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "html/template" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | "time" | |
| 8 | ||
| 9 | "gitbay.org/gitbay/internal/gitutil" | |
| 10 | "gitbay.org/gitbay/internal/store" | |
| 11 | "gitbay.org/gitbay/internal/web" | |
| 12 | ) | |
| 13 | ||
| 14 | // mrPageData mirrors the anonymous struct the mr handler renders with. | |
| 15 | type mrPageData struct { | |
| 16 | repoPage | |
| 17 | MR store.MR | |
| 18 | View string | |
| 19 | BodyHTML template.HTML | |
| 20 | Checks []store.Check | |
| 21 | Combined string | |
| 22 | Comments []renderedComment | |
| 23 | Reviews []store.MRReview | |
| 24 | DiffFiles []diffFile | |
| 25 | Stat diffStat | |
| 26 | Commits []struct{} | |
| 27 | Branches []gitutil.Ref | |
| 28 | CanEdit bool | |
| 29 | CanWrite bool | |
| 30 | Unresolved int | |
| 31 | Notice string | |
| 32 | DetachedThreads []diffThread | |
| 33 | } | |
| 34 | ||
| 35 | func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string { | |
| 36 | t.Helper() | |
| 37 | var sb strings.Builder | |
| 38 | if err := web.Render(&sb, "mr.html", mrPageData{ | |
| 39 | repoPage: testRepoPage(), MR: m, View: "conversation", | |
| 40 | Reviews: reviews, Checks: checks, Combined: "", | |
| 41 | }); err != nil { | |
| 42 | t.Fatalf("render: %v", err) | |
| 43 | } | |
| 44 | return sb.String() | |
| 45 | } | |
| 46 | ||
| 47 | func testMR(state string) store.MR { | |
| 48 | return store.MR{Number: 42, Title: "org native rendering", Author: "cmc", State: state, | |
| 49 | SourcePath: "krz/hutch", SourceRef: "org-native-rendering", TargetRef: "main", | |
| 50 | HeadSHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b"} | |
| 51 | } | |
| 52 | ||
| 53 | // The header states what happened to the MR. "wants to merge" is only true | |
| 54 | // while it is still open. | |
| 55 | func TestMRHeaderByState(t *testing.T) { | |
| 56 | open := renderMR(t, testMR("open"), nil, nil) | |
| 57 | if !strings.Contains(open, "wants to merge") { | |
| 58 | t.Errorf("open MR does not say wants to merge:\n%s", open) | |
| 59 | } | |
| 60 | ||
| 61 | merged := testMR("merged") | |
| 62 | merged.MergedAt, merged.MergedBy = "2026-08-27T14:03:11.000Z", "cmc" | |
| 63 | out := renderMR(t, merged, nil, nil) | |
| 64 | for _, want := range []string{"merged", "krz/hutch:org-native-rendering", "2026-08-27 14:03"} { | |
| 65 | if !strings.Contains(out, want) { | |
| 66 | t.Errorf("merged header missing %q:\n%s", want, out) | |
| 67 | } | |
| 68 | } | |
| 69 | if strings.Contains(out, "wants to merge") { | |
| 70 | t.Errorf("merged MR still wants to merge:\n%s", out) | |
| 71 | } | |
| 72 | ||
| 73 | closed := testMR("closed") | |
| 74 | closed.ClosedAt, closed.ClosedBy = "2026-08-27T14:03:11.000Z", "cmc" | |
| 75 | out = renderMR(t, closed, nil, nil) | |
| 76 | if !strings.Contains(out, "without merging") || strings.Contains(out, "wants to merge") { | |
| 77 | t.Errorf("closed header:\n%s", out) | |
| 78 | } | |
| 79 | ||
| 80 | // Imports and pre-0029 merges carry no stamp; the wording drops the | |
| 81 | // claim rather than inventing a time. | |
| 82 | out = renderMR(t, testMR("merged"), nil, nil) | |
| 83 | if strings.Contains(out, "wants to merge") || strings.Contains(out, " on 20") { | |
| 84 | t.Errorf("unstamped merged header:\n%s", out) | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | // Approvals and checks carry their times in the aside, so reading the MR | |
| 89 | // does not mean opening the build. | |
| 90 | func TestMRAsideTimestamps(t *testing.T) { | |
| 91 | out := renderMR(t, testMR("open"), | |
| 92 | []store.MRReview{{Reviewer: "cmc", Verdict: "approve", CreatedAt: "2026-08-27T14:03:11.000Z"}}, | |
| 93 | []store.Check{ | |
| 94 | {CommitStatus: store.CommitStatus{Context: "ci/test", State: "success", | |
| 95 | UpdatedAt: "2026-08-27T14:05:00.000Z"}, Duration: 72 * time.Second, Build: 60}, | |
| 96 | {CommitStatus: store.CommitStatus{Context: "external/lint", State: "success", | |
| 97 | UpdatedAt: "2026-08-27T14:06:00.000Z"}}, | |
| 98 | }) | |
| 99 | for _, want := range []string{"2026-08-27 14:03", "2026-08-27 14:05", "1m12s", "2026-08-27 14:06"} { | |
| 100 | if !strings.Contains(out, want) { | |
| 101 | t.Errorf("aside missing %q:\n%s", want, out) | |
| 102 | } | |
| 103 | } | |
| 104 | } | |
internal/httpd/web.go +3 −3
| @@ -1632,7 +1632,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1632 | 1632 | } |
| 1633 | 1633 | comments, _ := s.st.ListMRComments(m.ID) |
| 1634 | 1634 | reviews, _ := s.st.ListMRReviews(m.ID) |
| 1635 | checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA) | |
| 1635 | checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA) | |
| 1636 | 1636 | diffComments, _ := s.st.ListDiffComments(m.ID) |
| 1637 | 1637 | |
| 1638 | 1638 | headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number) |
| @@ -1696,7 +1696,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1696 | 1696 | MR store.MR |
| 1697 | 1697 | View string |
| 1698 | 1698 | BodyHTML template.HTML |
| 1699 | Checks []store.CommitStatus | |
| 1699 | Checks []store.Check | |
| 1700 | 1700 | Combined string |
| 1701 | 1701 | Comments []renderedComment |
| 1702 | 1702 | Reviews []store.MRReview |
| @@ -1709,7 +1709,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1709 | 1709 | Unresolved int |
| 1710 | 1710 | Notice string |
| 1711 | 1711 | DetachedThreads []diffThread |
| 1712 | }{p, m, view, md(m.Body, m.BodyFormat), checks, store.CombinedStatus(checks), renderComments(comments, md), | |
| 1712 | }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), | |
| 1713 | 1713 | reviews, files, stat, commits, branches, s.canEditItem(r, p.Repo, m.Author), |
| 1714 | 1714 | canWrite, unresolved, r.URL.Query().Get("e"), detachedThreads}) |
| 1715 | 1715 | } |
internal/store/builds.go +38
| @@ -206,3 +206,41 @@ func (s *Store) LatestBuild(repoID int64, job string) (Build, error) { | ||
| 206 | 206 | } |
| 207 | 207 | return b, err |
| 208 | 208 | } |
| 209 | ||
| 210 | // BuildsForCommit returns the newest build per job for one commit. A merge | |
| 211 | // request's checks are ci/<job> statuses; this is where their timing comes | |
| 212 | // from, in one query rather than one per check. | |
| 213 | func (s *Store) BuildsForCommit(repoID int64, sha string) (map[string]Build, error) { | |
| 214 | rows, err := s.DB.Query(buildSelect+" WHERE repo_id = ? AND sha = ? ORDER BY number ASC", repoID, sha) | |
| 215 | if err != nil { | |
| 216 | return nil, err | |
| 217 | } | |
| 218 | defer rows.Close() | |
| 219 | out := map[string]Build{} | |
| 220 | for rows.Next() { | |
| 221 | b, err := scanBuild(rows) | |
| 222 | if err != nil { | |
| 223 | return nil, err | |
| 224 | } | |
| 225 | out[b.Job] = b // ascending: the last row for a job wins | |
| 226 | } | |
| 227 | return out, rows.Err() | |
| 228 | } | |
| 229 | ||
| 230 | // Elapsed reports how long a build ran. Zero until it has both a start and | |
| 231 | // a finish, which is every state but success and failure. | |
| 232 | func (b Build) Elapsed() time.Duration { | |
| 233 | const layout = "2006-01-02T15:04:05Z" | |
| 234 | start, err := time.Parse(layout, b.StartedAt) | |
| 235 | if err != nil { | |
| 236 | return 0 | |
| 237 | } | |
| 238 | end, err := time.Parse(layout, b.FinishedAt) | |
| 239 | if err != nil { | |
| 240 | return 0 | |
| 241 | } | |
| 242 | if d := end.Sub(start); d > 0 { | |
| 243 | return d.Round(time.Second) | |
| 244 | } | |
| 245 | return 0 | |
| 246 | } | |
internal/store/builds_test.go +49
| @@ -69,3 +69,52 @@ func TestReapStaleBuilds(t *testing.T) { | ||
| 69 | 69 | t.Fatalf("fresh build is %v (%v), want running", b.Status, err) |
| 70 | 70 | } |
| 71 | 71 | } |
| 72 | ||
| 73 | // Checks on a merge request report how long their build ran, which means | |
| 74 | // pairing ci/<job> statuses with builds on the same commit. | |
| 75 | func TestBuildsForCommitTiming(t *testing.T) { | |
| 76 | s := open(t) | |
| 77 | if err := s.MigrateUp(); err != nil { | |
| 78 | t.Fatal(err) | |
| 79 | } | |
| 80 | uid, err := s.CreateUser("cmc", true) | |
| 81 | if err != nil { | |
| 82 | t.Fatal(err) | |
| 83 | } | |
| 84 | repoID, err := s.CreateRepo("user", uid, "lib", "public") | |
| 85 | if err != nil { | |
| 86 | t.Fatal(err) | |
| 87 | } | |
| 88 | // Two runs of the same job on one commit: the retry is what counts. | |
| 89 | for range 2 { | |
| 90 | if _, err := s.CreateBuild(repoID, "test", "abc123", "main", `["true"]`); err != nil { | |
| 91 | t.Fatal(err) | |
| 92 | } | |
| 93 | } | |
| 94 | if _, err := s.CreateBuild(repoID, "lint", "def456", "main", `["true"]`); err != nil { | |
| 95 | t.Fatal(err) | |
| 96 | } | |
| 97 | if _, err := s.DB.Exec(`UPDATE builds SET started_at = '2026-08-28T04:42:54Z', | |
| 98 | finished_at = '2026-08-28T04:44:06Z', status = 'success' WHERE number = 2`); err != nil { | |
| 99 | t.Fatal(err) | |
| 100 | } | |
| 101 | ||
| 102 | byJob, err := s.BuildsForCommit(repoID, "abc123") | |
| 103 | if err != nil { | |
| 104 | t.Fatal(err) | |
| 105 | } | |
| 106 | if len(byJob) != 1 { | |
| 107 | t.Fatalf("builds for commit: %+v", byJob) | |
| 108 | } | |
| 109 | b := byJob["test"] | |
| 110 | if b.Number != 2 { | |
| 111 | t.Fatalf("older run won: %d", b.Number) | |
| 112 | } | |
| 113 | if got := b.Elapsed().String(); got != "1m12s" { | |
| 114 | t.Fatalf("elapsed: %s", got) | |
| 115 | } | |
| 116 | // A build that never finished has no duration to report. | |
| 117 | if d := byJob["lint"].Elapsed(); d != 0 { | |
| 118 | t.Fatalf("unfinished build reported %s", d) | |
| 119 | } | |
| 120 | } | |
internal/store/migrations/0029_mr_resolution.down.sql added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | ALTER TABLE merge_requests DROP COLUMN closed_by; | |
| 2 | ALTER TABLE merge_requests DROP COLUMN closed_at; | |
| 3 | ALTER TABLE merge_requests DROP COLUMN merged_by; | |
| 4 | ALTER TABLE merge_requests DROP COLUMN merged_at; | |
internal/store/migrations/0029_mr_resolution.up.sql added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | -- Who resolved a merge request and when. The state alone cannot say it: | |
| 2 | -- updated_at moves for every edit, and the actor only survived in the | |
| 3 | -- events table. | |
| 4 | ALTER TABLE merge_requests ADD COLUMN merged_at TEXT NOT NULL DEFAULT ''; | |
| 5 | ALTER TABLE merge_requests ADD COLUMN merged_by INTEGER REFERENCES users(id) ON DELETE SET NULL; | |
| 6 | ALTER TABLE merge_requests ADD COLUMN closed_at TEXT NOT NULL DEFAULT ''; | |
| 7 | ALTER TABLE merge_requests ADD COLUMN closed_by INTEGER REFERENCES users(id) ON DELETE SET NULL; | |
| 8 | ||
| 9 | -- Backfill from the mr.merged events already recorded. json_extract is | |
| 10 | -- available in the SQLite builds gitbay links; the payload is | |
| 11 | -- {"number":N,"sha":"..."} written by the merge path. | |
| 12 | UPDATE merge_requests SET | |
| 13 | merged_at = COALESCE(( | |
| 14 | SELECT e.created_at FROM events e | |
| 15 | WHERE e.repo_id = merge_requests.repo_id AND e.kind = 'mr.merged' | |
| 16 | AND json_extract(e.data_json, '$.number') = merge_requests.number | |
| 17 | ORDER BY e.id DESC LIMIT 1), ''), | |
| 18 | merged_by = ( | |
| 19 | SELECT e.actor_id FROM events e | |
| 20 | WHERE e.repo_id = merge_requests.repo_id AND e.kind = 'mr.merged' | |
| 21 | AND json_extract(e.data_json, '$.number') = merge_requests.number | |
| 22 | ORDER BY e.id DESC LIMIT 1) | |
| 23 | WHERE state = 'merged'; | |
internal/store/mrs.go +38 −7
| @@ -22,6 +22,10 @@ type MR struct { | ||
| 22 | 22 | Milestone string |
| 23 | 23 | HeadSHA string |
| 24 | 24 | MergedBase string // target tip at merge time; base for historical diffs |
| 25 | MergedAt string // "" unless merged | |
| 26 | MergedBy string // "" when unknown (imports) or the account is gone | |
| 27 | ClosedAt string // "" unless closed without merging | |
| 28 | ClosedBy string | |
| 25 | 29 | CreatedAt string |
| 26 | 30 | UpdatedAt string |
| 27 | 31 | } |
| @@ -62,9 +66,12 @@ const mrSelect = ` | ||
| 62 | 66 | COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''), |
| 63 | 67 | m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, |
| 64 | 68 | COALESCE(ms.title, ''), m.head_sha, |
| 65 | m.merged_base, m.created_at, m.updated_at | |
| 69 | m.merged_base, m.merged_at, COALESCE(mu.username, ''), | |
| 70 | m.closed_at, COALESCE(cu.username, ''), m.created_at, m.updated_at | |
| 66 | 71 | FROM merge_requests m |
| 67 | 72 | JOIN users u ON u.id = m.author_id |
| 73 | LEFT JOIN users mu ON mu.id = m.merged_by | |
| 74 | LEFT JOIN users cu ON cu.id = m.closed_by | |
| 68 | 75 | LEFT JOIN repos sr ON sr.id = m.source_repo_id |
| 69 | 76 | LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id |
| 70 | 77 | LEFT JOIN orgs so ON sr.owner_kind = 'org' AND so.id = sr.owner_id |
| @@ -73,7 +80,8 @@ const mrSelect = ` | ||
| 73 | 80 | func scanMR(row interface{ Scan(...any) error }) (MR, error) { |
| 74 | 81 | var m MR |
| 75 | 82 | err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, |
| 76 | &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Milestone, &m.HeadSHA, &m.MergedBase, &m.CreatedAt, &m.UpdatedAt) | |
| 83 | &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Milestone, &m.HeadSHA, &m.MergedBase, | |
| 84 | &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.CreatedAt, &m.UpdatedAt) | |
| 77 | 85 | return m, err |
| 78 | 86 | } |
| 79 | 87 | |
| @@ -142,17 +150,40 @@ func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, err | ||
| 142 | 150 | } |
| 143 | 151 | |
| 144 | 152 | // MarkMerged records the merge along with the target tip it landed on, so |
| 145 | // the MR's diff stays reconstructable after fast-forwards. | |
| 146 | func (s *Store) MarkMerged(mrID int64, baseSHA string) error { | |
| 153 | // the MR's diff stays reconstructable after fast-forwards. actorID 0 and an | |
| 154 | // empty at leave the merger unknown and stamp the current time, which is | |
| 155 | // what an import that carries neither can say. | |
| 156 | func (s *Store) MarkMerged(mrID int64, baseSHA string, actorID int64, at string) error { | |
| 147 | 157 | _, err := s.DB.Exec( |
| 148 | "UPDATE merge_requests SET state = 'merged', merged_base = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", | |
| 149 | baseSHA, mrID) | |
| 158 | `UPDATE merge_requests SET state = 'merged', merged_base = ?, | |
| 159 | merged_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 160 | merged_by = NULLIF(?, 0), | |
| 161 | updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, | |
| 162 | baseSHA, at, actorID, mrID) | |
| 150 | 163 | return err |
| 151 | 164 | } |
| 152 | 165 | |
| 166 | // MarkClosed is MarkMerged's counterpart for a merge request closed without | |
| 167 | // merging. | |
| 168 | func (s *Store) MarkClosed(mrID, actorID int64, at string) error { | |
| 169 | _, err := s.DB.Exec( | |
| 170 | `UPDATE merge_requests SET state = 'closed', | |
| 171 | closed_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 172 | closed_by = NULLIF(?, 0), | |
| 173 | updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, | |
| 174 | at, actorID, mrID) | |
| 175 | return err | |
| 176 | } | |
| 177 | ||
| 178 | // SetMRState moves an MR between states that carry no resolution stamp. | |
| 179 | // Returning to open (a source branch that came back) clears one. | |
| 153 | 180 | func (s *Store) SetMRState(mrID int64, state string) error { |
| 181 | stamp := "" | |
| 182 | if state == "open" || state == "source_gone" { | |
| 183 | stamp = ", merged_at = '', merged_by = NULL, closed_at = '', closed_by = NULL" | |
| 184 | } | |
| 154 | 185 | res, err := s.DB.Exec( |
| 155 | "UPDATE merge_requests SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", | |
| 186 | "UPDATE merge_requests SET state = ?"+stamp+", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", | |
| 156 | 187 | state, mrID) |
| 157 | 188 | if err != nil { |
| 158 | 189 | return err |
internal/store/mrs_test.go added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import "testing" | |
| 4 | ||
| 5 | func mrFixture(t *testing.T) (*Store, int64, int64) { | |
| 6 | t.Helper() | |
| 7 | s := open(t) | |
| 8 | if err := s.MigrateUp(); err != nil { | |
| 9 | t.Fatal(err) | |
| 10 | } | |
| 11 | uid, err := s.CreateUser("cmc", true) | |
| 12 | if err != nil { | |
| 13 | t.Fatal(err) | |
| 14 | } | |
| 15 | repoID, err := s.CreateRepo("user", uid, "lib", "public") | |
| 16 | if err != nil { | |
| 17 | t.Fatal(err) | |
| 18 | } | |
| 19 | if _, err := s.CreateMR(repoID, uid, repoID, "feature", "main", "t", "", "abc123", "md"); err != nil { | |
| 20 | t.Fatal(err) | |
| 21 | } | |
| 22 | return s, repoID, uid | |
| 23 | } | |
| 24 | ||
| 25 | // A merged or closed MR records who resolved it and when: the state alone | |
| 26 | // cannot say it, and updated_at moves for every edit. | |
| 27 | func TestResolutionStamps(t *testing.T) { | |
| 28 | s, repoID, uid := mrFixture(t) | |
| 29 | mr, err := s.MRByNumber(repoID, 1) | |
| 30 | if err != nil { | |
| 31 | t.Fatal(err) | |
| 32 | } | |
| 33 | if mr.MergedAt != "" || mr.ClosedAt != "" { | |
| 34 | t.Fatalf("open MR carries a stamp: %+v", mr) | |
| 35 | } | |
| 36 | if err := s.MarkMerged(mr.ID, "base1", uid, ""); err != nil { | |
| 37 | t.Fatal(err) | |
| 38 | } | |
| 39 | mr, _ = s.MRByNumber(repoID, 1) | |
| 40 | if mr.State != "merged" || mr.MergedAt == "" || mr.MergedBy != "cmc" { | |
| 41 | t.Fatalf("merge stamp: %+v", mr) | |
| 42 | } | |
| 43 | ||
| 44 | // Reopening — a source branch that came back — clears the stamp. | |
| 45 | if err := s.SetMRState(mr.ID, "open"); err != nil { | |
| 46 | t.Fatal(err) | |
| 47 | } | |
| 48 | mr, _ = s.MRByNumber(repoID, 1) | |
| 49 | if mr.MergedAt != "" || mr.MergedBy != "" { | |
| 50 | t.Fatalf("reopen kept the merge stamp: %+v", mr) | |
| 51 | } | |
| 52 | ||
| 53 | if err := s.MarkClosed(mr.ID, uid, ""); err != nil { | |
| 54 | t.Fatal(err) | |
| 55 | } | |
| 56 | mr, _ = s.MRByNumber(repoID, 1) | |
| 57 | if mr.State != "closed" || mr.ClosedAt == "" || mr.ClosedBy != "cmc" { | |
| 58 | t.Fatalf("close stamp: %+v", mr) | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | // An import carries the upstream time but no local account for the actor. | |
| 63 | func TestResolutionStampImported(t *testing.T) { | |
| 64 | s, repoID, _ := mrFixture(t) | |
| 65 | mr, _ := s.MRByNumber(repoID, 1) | |
| 66 | if err := s.MarkMerged(mr.ID, "base1", 0, "2024-03-02T10:00:00Z"); err != nil { | |
| 67 | t.Fatal(err) | |
| 68 | } | |
| 69 | mr, _ = s.MRByNumber(repoID, 1) | |
| 70 | if mr.MergedAt != "2024-03-02T10:00:00Z" || mr.MergedBy != "" { | |
| 71 | t.Fatalf("imported merge stamp: %+v", mr) | |
| 72 | } | |
| 73 | } | |
internal/store/statuses.go +42 −1
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | package store |
| 2 | 2 | |
| 3 | import "strings" | |
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "time" | |
| 6 | ) | |
| 4 | 7 | |
| 5 | 8 | type CommitStatus struct { |
| 6 | 9 | Context string |
| @@ -106,3 +109,41 @@ func CombinedStatus(statuses []CommitStatus) string { | ||
| 106 | 109 | } |
| 107 | 110 | return combined |
| 108 | 111 | } |
| 112 | ||
| 113 | // Check is a commit status paired with the build behind it. Only ci/<job> | |
| 114 | // statuses have one; anything posted through `status set` reports just the | |
| 115 | // state and when it last changed. | |
| 116 | type Check struct { | |
| 117 | CommitStatus | |
| 118 | Build int64 // build number, 0 when the check is not CI's | |
| 119 | Duration time.Duration // 0 until that build has finished | |
| 120 | } | |
| 121 | ||
| 122 | // ChecksForCommit lists a commit's checks with their timing, and reduces | |
| 123 | // them to one combined state. Two queries at most, whatever the surface. | |
| 124 | func (s *Store) ChecksForCommit(repoID int64, sha string) ([]Check, string, error) { | |
| 125 | statuses, err := s.ListCommitStatuses(repoID, sha) | |
| 126 | if err != nil { | |
| 127 | return nil, "", err | |
| 128 | } | |
| 129 | var builds map[string]Build | |
| 130 | for _, st := range statuses { | |
| 131 | if strings.HasPrefix(st.Context, "ci/") { | |
| 132 | if builds, err = s.BuildsForCommit(repoID, sha); err != nil { | |
| 133 | return nil, "", err | |
| 134 | } | |
| 135 | break | |
| 136 | } | |
| 137 | } | |
| 138 | checks := make([]Check, 0, len(statuses)) | |
| 139 | for _, st := range statuses { | |
| 140 | c := Check{CommitStatus: st} | |
| 141 | if job, ok := strings.CutPrefix(st.Context, "ci/"); ok { | |
| 142 | if b, ok := builds[job]; ok { | |
| 143 | c.Build, c.Duration = b.Number, b.Elapsed() | |
| 144 | } | |
| 145 | } | |
| 146 | checks = append(checks, c) | |
| 147 | } | |
| 148 | return checks, CombinedStatus(statuses), nil | |
| 149 | } | |
internal/store/statuses_test.go added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import "testing" | |
| 4 | ||
| 5 | // Checks carry the timing of the build behind them. Statuses posted from | |
| 6 | // outside CI have no build, and must not borrow one. | |
| 7 | func TestChecksForCommit(t *testing.T) { | |
| 8 | s := open(t) | |
| 9 | if err := s.MigrateUp(); err != nil { | |
| 10 | t.Fatal(err) | |
| 11 | } | |
| 12 | uid, err := s.CreateUser("cmc", true) | |
| 13 | if err != nil { | |
| 14 | t.Fatal(err) | |
| 15 | } | |
| 16 | repoID, err := s.CreateRepo("user", uid, "lib", "public") | |
| 17 | if err != nil { | |
| 18 | t.Fatal(err) | |
| 19 | } | |
| 20 | if _, err := s.CreateBuild(repoID, "test", "abc123", "main", `["true"]`); err != nil { | |
| 21 | t.Fatal(err) | |
| 22 | } | |
| 23 | if _, err := s.DB.Exec(`UPDATE builds SET started_at = '2026-08-28T04:42:54Z', | |
| 24 | finished_at = '2026-08-28T04:44:06Z', status = 'success' WHERE number = 1`); err != nil { | |
| 25 | t.Fatal(err) | |
| 26 | } | |
| 27 | for _, c := range []struct{ context, state string }{ | |
| 28 | {"ci/test", "success"}, | |
| 29 | {"ci/absent", "pending"}, // a status with no build of its own | |
| 30 | {"external/lint", "success"}, | |
| 31 | } { | |
| 32 | if err := s.SetCommitStatus(repoID, "abc123", c.context, c.state, "", "", uid); err != nil { | |
| 33 | t.Fatal(err) | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | checks, combined, err := s.ChecksForCommit(repoID, "abc123") | |
| 38 | if err != nil { | |
| 39 | t.Fatal(err) | |
| 40 | } | |
| 41 | if combined != "pending" { | |
| 42 | t.Fatalf("combined: %s", combined) | |
| 43 | } | |
| 44 | byContext := map[string]Check{} | |
| 45 | for _, c := range checks { | |
| 46 | if c.UpdatedAt == "" { | |
| 47 | t.Errorf("%s has no timestamp", c.Context) | |
| 48 | } | |
| 49 | byContext[c.Context] = c | |
| 50 | } | |
| 51 | if got := byContext["ci/test"]; got.Build != 1 || got.Duration.String() != "1m12s" { | |
| 52 | t.Errorf("ci/test timing: build %d, %s", got.Build, got.Duration) | |
| 53 | } | |
| 54 | for _, ctx := range []string{"ci/absent", "external/lint"} { | |
| 55 | if got := byContext[ctx]; got.Build != 0 || got.Duration != 0 { | |
| 56 | t.Errorf("%s borrowed timing: build %d, %s", ctx, got.Build, got.Duration) | |
| 57 | } | |
| 58 | } | |
| 59 | } | |
internal/web/static/style.css +4
| @@ -1492,6 +1492,10 @@ p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp- | ||
| 1492 | 1492 | .aside .row { display: flex; align-items: center; gap: var(--sp-2); margin: 0 0 var(--sp-1); flex-wrap: wrap; } |
| 1493 | 1493 | .aside .row:last-child { margin-bottom: 0; } |
| 1494 | 1494 | .aside .none { color: var(--muted); } |
| 1495 | /* a row's second line: when it happened, under what it happened to. The | |
| 1496 | extra gap keeps the pair reading as one entry. */ | |
| 1497 | .aside .row .sub { flex-basis: 100%; color: var(--muted); font-size: var(--fs-0); } | |
| 1498 | .aside .row:has(.sub) { margin-bottom: var(--sp-2); } | |
| 1495 | 1499 | .aside .dot { width: 0.45rem; height: 0.45rem; flex: none; border-radius: 50%; background: var(--muted); } |
| 1496 | 1500 | .aside .dot.ok { background: var(--ok); } |
| 1497 | 1501 | .aside .dot.bad { background: var(--bad); } |
internal/web/templates/mr.html +14 −3
| @@ -1,9 +1,20 @@ | ||
| 1 | {{define "mrrange"}}<code>{{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}}</code> into <code>{{.TargetRef}}</code>{{end}} | |
| 1 | 2 | {{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 3 | {{define "content"}} |
| 3 | 4 | {{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}} |
| 4 | 5 | <h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1> |
| 5 | 6 | <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> |
| 6 | <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge <code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p> | |
| 7 | {{if and (eq .MR.State "merged") .MR.MergedAt}} | |
| 8 | {{if .MR.MergedBy}}<a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a> merged{{else}}Merged{{end}} | |
| 9 | {{template "mrrange" .MR}} on {{when .MR.MergedAt}} | |
| 10 | {{else if and (eq .MR.State "closed") .MR.ClosedAt}} | |
| 11 | {{if .MR.ClosedBy}}<a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a> closed this{{else}}Closed{{end}} | |
| 12 | without merging {{template "mrrange" .MR}} on {{when .MR.ClosedAt}} | |
| 13 | {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}} | |
| 14 | {{template "mrrange" .MR}} | |
| 15 | {{else}} | |
| 16 | <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{template "mrrange" .MR}} | |
| 17 | {{end}}</p> | |
| 7 | 18 | |
| 8 | 19 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 9 | 20 | |
| @@ -109,13 +120,13 @@ | ||
| 109 | 120 | {{end}} |
| 110 | 121 | <div class="grp"> |
| 111 | 122 | <h2>Reviews</h2> |
| 112 | {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}</p> | |
| 123 | {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}<span class="sub">{{when .CreatedAt}}</span></p> | |
| 113 | 124 | {{else}}<p class="none">No reviews yet</p>{{end}} |
| 114 | 125 | </div> |
| 115 | 126 | <div class="grp"> |
| 116 | 127 | <h2>Checks</h2> |
| 117 | 128 | {{if .Checks}}<p class="row"><span class="badge check-{{.Combined}}">{{.Combined}}</span></p> |
| 118 | {{range .Checks}}<p class="row"><span class="dot {{if eq .State "success"}}ok{{else if eq .State "failure"}}bad{{else}}pend{{end}}"></span>{{if .TargetURL}}<a href="{{.TargetURL}}" rel="nofollow">{{.Context}}</a>{{else}}{{.Context}}{{end}} <span class="none">{{.State}}</span></p>{{end}} | |
| 129 | {{range .Checks}}<p class="row"><span class="dot {{if eq .State "success"}}ok{{else if eq .State "failure"}}bad{{else}}pend{{end}}"></span>{{if .TargetURL}}<a href="{{.TargetURL}}" rel="nofollow">{{.Context}}</a>{{else}}{{.Context}}{{end}} <span class="none">{{.State}}</span><span class="sub">{{when .UpdatedAt}}{{if .Duration}} · {{.Duration}}{{end}}</span></p>{{end}} | |
| 119 | 130 | {{else}}<p class="none">No checks reported</p>{{end}} |
| 120 | 131 | </div> |
| 121 | 132 | <div class="grp"> |