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")
}
}
// A heading precedes the comment thread, so a screen-reader user
// skimming by heading does not fall from the aside's groups straight
// into the first comment with no landmark (#271).
func TestMRPageHasDiscussionHeading(t *testing.T) {
var sb strings.Builder
if err := web.Render(&sb, "mr.html", mrPageData{
repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
}); err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(sb.String(), "
Discussion
") {
t.Error("no Discussion heading")
}
}
// The watch button names all three states it cycles through, including
// muted, which MR 1 made reachable (#271).
func TestRepoHeaderWatchButtonNamesMutedState(t *testing.T) {
var sb strings.Builder
rp := testRepoPage()
rp.Viewer = "cmc" // the watch button only renders for a signed-in viewer
rp.Watch = "muted"
if err := web.Render(&sb, "mr.html", mrPageData{
repoPage: rp, MR: testMR("open"), View: "conversation",
}); err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(sb.String(), "Muted") {
t.Error(`watch button does not render "Muted" for a muted repo`)
}
}
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"}
}
// A finished merge request states an empty label list as a fact, not a
// promise something is still coming (#270).
func TestMRPageLabelsNoYetOnFinishedState(t *testing.T) {
var sb strings.Builder
if err := web.Render(&sb, "mr.html", mrPageData{
repoPage: testRepoPage(), MR: testMR("merged"), View: "conversation",
}); err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(sb.String(), "no labels") {
t.Error(`merged MR with no labels should read "no labels", not "none yet"`)
}
}
// An open MR can still gain labels, so its empty state keeps "none yet"
// rather than the finished-item wording (#270).
func TestMRPageLabelsNoneYetOnOpenState(t *testing.T) {
var sb strings.Builder
if err := web.Render(&sb, "mr.html", mrPageData{
repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
}); err != nil {
t.Fatalf("render: %v", err)
}
out := sb.String()
if !strings.Contains(out, "none yet") {
t.Error(`open MR with no labels should read "none yet"`)
}
if strings.Contains(out, "no labels") {
t.Error(`open MR with no labels should not read "no labels"`)
}
}
// A closed MR is finished the same way a merged one is (#270).
func TestMRPageLabelsNoLabelsOnClosedState(t *testing.T) {
var sb strings.Builder
if err := web.Render(&sb, "mr.html", mrPageData{
repoPage: testRepoPage(), MR: testMR("closed"), View: "conversation",
}); err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(sb.String(), "no labels") {
t.Error(`closed MR with no labels should read "no labels", not "none yet"`)
}
}
func TestMRPageReviewersEmptyStateDropsNobody(t *testing.T) {
var sb strings.Builder
if err := web.Render(&sb, "mr.html", mrPageData{
repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
}); err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(sb.String(), "nobody yet") {
t.Error(`reviewers empty state should read "no reviewers"`)
}
if !strings.Contains(sb.String(), "no reviewers") {
t.Error(`missing "no reviewers"`)
}
}
// 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(&control.GatesOut{Checks: "pending", ChecksMissing: []string{"ext/deploy"}}); !strings.Contains(out, "waiting on ext/deploy") {
t.Errorf("missing required context 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)
}
}
// Each revision after the first carries a link comparing it to the one
// before, so a reviewer does not have to type mr range-diff by hand
// (#269).
func TestMRPageListsRevisionsWithCompareLinks(t *testing.T) {
var sb strings.Builder
revs := []store.MRHead{
{SHA: "aaaa1111", CreatedAt: "2026-09-23T10:00:00Z"},
{SHA: "bbbb2222", CreatedAt: "2026-09-24T10:00:00Z"},
}
if err := web.Render(&sb, "mr.html", mrPageData{
repoPage: testRepoPage(), MR: testMR("open"), View: "conversation", Revisions: revs,
}); err != nil {
t.Fatalf("render: %v", err)
}
out := sb.String()
for _, want := range []string{"aaaa1111", "bbbb2222", "compare to previous", "from=aaaa1111", "to=bbbb2222"} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "gitbay mr range-diff") {
t.Error("still quotes the CLI command instead of linking the new page")
}
}