package httpd import ( "html/template" "strings" "testing" "time" "gitbay.org/gitbay/internal/control" "gitbay.org/gitbay/internal/gitutil" "gitbay.org/gitbay/internal/store" "gitbay.org/gitbay/internal/web" ) // mrPageData mirrors the anonymous struct the mr handler renders with. type mrPageData struct { repoPage MR store.MR View string BodyHTML template.HTML Checks []store.Check Combined string Comments []renderedComment Reviews []reviewRow DiffFiles []diffFile Stat diffStat Commits []struct{} Branches []gitutil.Ref CanEdit bool CanWrite bool Unresolved int Revisions []store.MRHead Notice string DetachedThreads []diffThread Gates *control.GatesOut SourceGone bool HeadMerged bool HeadPruned bool Base string Draft *draft } // A pruned head has no diff to show; the page must say the head is gone // rather than that nothing changed, which is what an empty file list // otherwise renders as. func TestMRDiffViewNamesAPrunedHead(t *testing.T) { var sb strings.Builder if err := web.Render(&sb, "mr.html", mrPageData{ repoPage: testRepoPage(), MR: testMR("merged"), View: "diff", HeadPruned: true, }); err != nil { t.Fatalf("render: %v", err) } out := sb.String() if !strings.Contains(out, "no longer in the repository") { t.Error("diff view of a pruned head does not say the head is gone") } if strings.Contains(out, "No changes between") { t.Error("diff view of a pruned head claims there were no changes") } } func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string { rows := make([]reviewRow, 0, len(reviews)) for _, r := range reviews { // The page test renders reviews that count; whether a given // reviewer's does is decided by access, which e2e covers. rows = append(rows, reviewRow{MRReview: r, Counts: true}) } t.Helper() var sb strings.Builder if err := web.Render(&sb, "mr.html", mrPageData{ repoPage: testRepoPage(), MR: m, View: "conversation", Reviews: rows, Checks: checks, Combined: "", }); err != nil { t.Fatalf("render: %v", err) } return sb.String() } func testMR(state string) store.MR { return store.MR{Number: 42, Title: "org native rendering", Author: "cmc", State: state, SourcePath: "krz/hutch", SourceRef: "org-native-rendering", TargetRef: "main", HeadSHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b"} } // The header states what happened to the MR: who opened, merged, or closed // it, and when. A merge or close with no stamp (imports, pre-0029 merges) // names the state without claiming a byline it cannot back up. func TestMRHeaderByState(t *testing.T) { open := renderMR(t, testMR("open"), nil, nil) if !strings.Contains(open, "opened by") { t.Errorf("open MR does not say who opened it:\n%s", open) } merged := testMR("merged") merged.MergedAt, merged.MergedBy = "2026-08-27T14:03:11.000Z", "cmc" out := renderMR(t, merged, nil, nil) for _, want := range []string{"merged", "krz/hutch:org-native-rendering", "2026-08-27 14:03"} { if !strings.Contains(out, want) { t.Errorf("merged header missing %q:\n%s", want, out) } } if strings.Contains(out, "opened by") { t.Errorf("merged MR still says it was opened:\n%s", out) } closed := testMR("closed") closed.ClosedAt, closed.ClosedBy = "2026-08-27T14:03:11.000Z", "cmc" out = renderMR(t, closed, nil, nil) if !strings.Contains(out, "without merging") || strings.Contains(out, "opened by") { t.Errorf("closed header:\n%s", out) } // Imports and pre-0029 merges carry no stamp; the wording drops the // byline claim rather than naming nobody or inventing a time. out = renderMR(t, testMR("merged"), nil, nil) if strings.Contains(out, "opened by") || strings.Contains(out, " on 20") || strings.Contains(out, "merged by") { t.Errorf("unstamped merged header:\n%s", out) } if !strings.Contains(out, "merged") { t.Errorf("unstamped merged header drops the state entirely:\n%s", out) } out = renderMR(t, testMR("closed"), nil, nil) if strings.Contains(out, "opened by") || strings.Contains(out, " on 20") || strings.Contains(out, "closed without merging by") { t.Errorf("unstamped closed header:\n%s", out) } if !strings.Contains(out, "without merging") { t.Errorf("unstamped closed header drops the state entirely:\n%s", out) } } // Approvals and checks carry their times in the aside, so reading the MR // does not mean opening the build. func TestMRAsideTimestamps(t *testing.T) { out := renderMR(t, testMR("open"), []store.MRReview{{Reviewer: "cmc", Verdict: "approve", CreatedAt: "2026-08-27T14:03:11.000Z"}}, []store.Check{ {CommitStatus: store.CommitStatus{Context: "ci/test", State: "success", UpdatedAt: "2026-08-27T14:05:00.000Z"}, Duration: 72 * time.Second, Build: 60}, {CommitStatus: store.CommitStatus{Context: "external/lint", State: "success", UpdatedAt: "2026-08-27T14:06:00.000Z"}}, }) for _, want := range []string{"2026-08-27 14:03", "2026-08-27 14:05", "1m12s", "2026-08-27 14:06"} { if !strings.Contains(out, want) { t.Errorf("aside missing %q:\n%s", want, out) } } } // A job a path filter excluded has no build behind its status: the page // must show it as skipped rather than linking to a build that never ran // (#172). func TestMRChecksRenderSkippedWithoutBuildLink(t *testing.T) { out := renderMR(t, testMR("open"), nil, []store.Check{ {CommitStatus: store.CommitStatus{Context: "ci/unit", State: "skipped", Description: "every changed file matched paths-ignore", UpdatedAt: "2026-08-27T14:05:00.000Z"}}, }) row := "" for _, line := range strings.Split(out, "\n") { if strings.Contains(line, "ci/unit") { row = line } } if row == "" || !strings.Contains(row, ">skipped<") { t.Fatalf("skipped check not rendered:\n%s", out) } if strings.Contains(row, "carol`, "not a fast-forward"} { if !strings.Contains(out, want) { t.Errorf("gates block missing %q:\n%s", want, out) } } if out := render(&control.GatesOut{FastForward: true}); !strings.Contains(out, "All gates met") || !strings.Contains(out, "fast-forward possible") { t.Errorf("met gates not rendered:\n%s", out) } if out := render(nil); strings.Contains(out, "Merge gates") { t.Errorf("gates block on a merge request without gates:\n%s", out) } }